From bbea1ee9d81cba1d8443f3f1ade723db158acf8d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 12:14:34 +0000 Subject: [PATCH 001/196] =?UTF-8?q?test(gateway):=20=E9=BB=91=E7=9B=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=87=8D=E5=86=99=2034=20=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20+=20v0.8.0=20=E5=A2=9E=E9=87=8F=E5=AE=A1=E8=AE=A1=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/blackbox/test_gateway.py 重写为 6 段 34 断言(输入校验/whoami+admin.verify/ captcha-image 代理/路径遍历矩阵/限流随机XFF隔离/CORS+脱敏回归),实测 32 passed/2 skipped - helpers.py 新增 random_xff/assert_no_internal_leak/db_execute - docs/security/gateway_SECURITY_AUDIT_20260809.md:A1-A8 增量复审(已全部修复) - docs/reports/libhttp_remote_addr_dev_doc.md:A4 根治方案(X-Remote-Addr 可信头注入) - docs/INDEX.md:security 29 篇/reports 12 篇同步 --- docs/INDEX.md | 8 +- docs/reports/libhttp_remote_addr_dev_doc.md | 179 +++++++ .../gateway_SECURITY_AUDIT_20260809.md | 265 ++++++++++ tests/blackbox/README.md | 2 +- tests/blackbox/helpers.py | 48 ++ tests/blackbox/test_gateway.py | 476 ++++++++++++------ 6 files changed, 821 insertions(+), 157 deletions(-) create mode 100644 docs/reports/libhttp_remote_addr_dev_doc.md create mode 100644 docs/security/gateway_SECURITY_AUDIT_20260809.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 60ff7b3..956d3d3 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,9 +7,9 @@ ``` docs/ ├── INDEX.md # 本文件(文档总索引) -├── security/ # 安全审计与加固(28 篇) +├── security/ # 安全审计与加固(29 篇) ├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(11 篇) +└── reports/ # 分析报告与开发规划(12 篇) ``` --- @@ -39,6 +39,7 @@ docs/ | waf | [waf_SECURITY_AUDIT_20260801.md](security/waf_SECURITY_AUDIT_20260801.md) | Go WAF 网关(TLS、coraza、反向代理) | | gateway | [gateway_SECURITY_AUDIT_20260801.md](security/gateway_SECURITY_AUDIT_20260801.md) | C++20 应用网关(鉴权、Token、反向代理)初版 | | gateway | [gateway_SECURITY_AUDIT_20260807.md](security/gateway_SECURITY_AUDIT_20260807.md) | C++20 应用网关复审版(P0全部修复,进程退出bug修复) | +| gateway | [gateway_SECURITY_AUDIT_20260809.md](security/gateway_SECURITY_AUDIT_20260809.md) | C++20 应用网关增量复审(8 项新发现:关闭崩溃、头名大小写架空XFF修复等;黑盒测试重写落地) | | core | [core_SECURITY_AUDIT_20260801.md](security/core_SECURITY_AUDIT_20260801.md) | C++20 进程托管服务(PMC/Nidus) | | db | [db_SECURITY_AUDIT_20260801.md](security/db_SECURITY_AUDIT_20260801.md) | Python 数据库服务(pymsg、SQLAlchemy、日志收集) | | db2 | [db2_SECURITY_AUDIT_20260806.md](security/db2_SECURITY_AUDIT_20260806.md) | C++20 数据库 RPC 服务(db 的 C++ 重实现,SQLite C API) | @@ -80,7 +81,7 @@ docs/ |------|------| | [blackbird_research_report.md](reports/blackbird_research_report.md) | blackbird 项目研究报告(安全架构分析、改进建议、里程碑规划) | | [core_service_registry_dev_doc.md](reports/core_service_registry_dev_doc.md) | Core 模块注册中心功能扩展开发文档(服务注册/发现/健康守护/自动重启) | -| [auth_service_plan.md](reports/auth_service_plan.md) | 权限系统独立化计划书(认证授权从 gateway 拆分为独立服务) | +| [auth_service_plan.md](reports/auth_service_plan.md) | 认证独立化(CA 模块)开发文档(waf→ca→gateway 前置拓扑;gateway 退 8445 纯转发不验签;下游/WAF/前端零改动) | | [aibox_open_dev_plan.md](reports/aibox_open_dev_plan.md) | aibox 开放版开发计划(命令注入漏洞修复、安全沙箱方案) | | [aibox_file_network_cli_refactor.md](reports/aibox_file_network_cli_refactor.md) | aibox 文件/网络操作命令行化重构计划 | | [blackbird_promo_full.md](reports/blackbird_promo_full.md) | blackbird 项目完整版宣传文案 | @@ -90,6 +91,7 @@ docs/ | [actpred_pretrain_test_20260808.md](reports/actpred_pretrain_test_20260808.md) | actpred 预训练与推理测试报告(action_dim=18 变更、.mt 解析器、预训练、推理基准、NPU 调研) | | [captcha_cpp_rewrite_dev_doc.md](reports/captcha_cpp_rewrite_dev_doc.md) | captcha 模块 C++20 重写开发方案(FreeType + stb_image_write 替代 Pillow,协议兼容零改动) | | [smtp_cpp_rewrite_dev_doc.md](reports/smtp_cpp_rewrite_dev_doc.md) | smtp 模块 C++20 重写开发方案(SMTP_SSL 客户端手写,审计 7 项遗留一并在重写中修复,零新增系统依赖) | +| [libhttp_remote_addr_dev_doc.md](reports/libhttp_remote_addr_dev_doc.md) | libhttp 对端地址透传开发方案(X-Remote-Addr 可信头注入,非破坏;gateway trust_xff 配套,闭环审计 A4) | --- diff --git a/docs/reports/libhttp_remote_addr_dev_doc.md b/docs/reports/libhttp_remote_addr_dev_doc.md new file mode 100644 index 0000000..b81fc13 --- /dev/null +++ b/docs/reports/libhttp_remote_addr_dev_doc.md @@ -0,0 +1,179 @@ +# libhttp 对端地址透传 · 开发文档 + +**日期**: 2026-08-09 +**背景**: gateway 审计 A4([gateway_SECURITY_AUDIT_20260809.md](../security/gateway_SECURITY_AUDIT_20260809.md))——独立部署模式 B 下 RateLimiter 以客户端可控的 `X-Forwarded-For` 为限流键,伪造 XFF 即绕过全部 IP 级限流。根治需要 libhttp 向 handler 透传 TCP 对端地址。 +**范围**: `libhttp`(注入实现)、`gateway`(消费方配套);其余消费方(core/dnfs2/fsys)零改动。 + +--- + +## 0. 摘要 + +libhttp 的 Session 层**已经持有**对端地址(accept 时 `setRemoteInfo()`),但未向请求处理链路透传:`HttpRequestHandler` 只收到 `(req, params)`。本方案在 Session 派发前向请求注入**可信私有头** `X-Remote-Addr`(先剥离客户端伪造值再写入对端 IP),实现非破坏性透传;gateway 侧新增 `trust_xff` 配置选择限流键,闭环 A4。 + +--- + +## 1. 现状分析 + +### 1.1 调用链与缺口 + +``` +Listener::onAccept ← socket.remote_endpoint() 在此可用 + └─ session->setRemoteInfo(ip, port) (HttpServer_Listener.cpp L50) + └─ Session::handleRead (解析完成,WS/HTTP 分流) + ├─ WS 路径:findWsProxyRoute(path)(request_) / findWsRoute + └─ HTTP 路径:processRequest(req) + └─ server_->handleRequest(req) (HttpServer_Request.cpp L8) + └─ handler(req, params) ← 对端地址在此丢失 +``` + +**缺口**: `remote_ip_`/`remote_port_` 是 `Session` 私有成员(`HttpServerImpl.hpp` L122-123),仅用于 SSL 握手与读写错误日志;`handleRequest`/`handler` 拿不到。 + +### 1.2 已有基础设施(无需新建) + +| 设施 | 位置 | 说明 | +|------|------|------| +| `Session::setRemoteInfo(ip, port)` | `HttpServerImpl.hpp` L60 | accept 时已被调用 | +| `Session::getRemoteAddress()` | `HttpServerImpl.hpp` L63 | `"ip:port"` 形式 | +| `remote_ip_` / `remote_port_` 成员 | `HttpServerImpl.hpp` L122-123 | 默认 `"unknown"` / 0 | +| WS 侧对端地址 | `WebSocketConnectHandler` | WS 路径已有 `remoteAddress` 回调参数,HTTP 路径独缺 | + +### 1.3 消费方清单(`us_http` 链接者) + +| 模块 | 受影响 | 说明 | +|------|--------|------| +| gateway | 配套改造 | 限流键切换 + 代理剥离私有头(A4 闭环) | +| core / dnfs2 / fsys | 零改动 | 非破坏方案下无感知 | + +--- + +## 2. 方案对比 + +| 方案 | 改动 | 破坏性 | 评价 | +|------|------|--------|------| +| **A. handler 签名扩展** | `HttpRequestHandler` 加第三参 `remote_addr` | 🔴 破坏:4 模块全部路由注册 lambda(`[](const auto& req, const auto& params)`)编译失败,gateway 约 15 处 | 类型安全但爆炸半径大;std::function 签名变更无渐进路径 | +| **B. 可信私有头注入** | Session 派发前 erase+set `X-Remote-Addr` | 🟢 非破坏:handler 签名不变,消费方 opt-in 读取 | **推荐**。与 WAF 覆写 XFF、gateway 注入可信 XFF 的既有模式同构 | +| **C. 新增 Ex 注册 API** | `HttpRequestHandlerEx` + `getEx/postEx/...` | 🟢 非破坏 | 长期双 API 并存,路由查找分叉,不值得 | + +**结论:选 B**。若未来确需类型安全(如更多连接元信息:TLS 指纹、证书序列号),可在 B 稳定后再评估 A,B 作为便利层保留。 + +--- + +## 3. 方案 B 详细设计 + +### 3.1 注入点与语义 + +**注入点**: `Session::handleRead()` 解析成功分支(`HttpServer_Request.cpp` L266 `if (!ec)` 内、WS 分流判断**之前**)。单点覆盖 HTTP 与 WS-upgrade 两条路径,且 keep-alive 连接复用时每个请求独立注入(对端地址在同一连接内不变,重复注入无害)。 + +**语义(strip-then-set,防伪造)**: + +```cpp +// handleRead() if (!ec) 分支最前面: +// 注入可信对端地址:先剥离客户端伪造值,再写入 socket 对端 IP(A4 根治) +request_.erase("X-Remote-Addr"); // Beast fields 大小写不敏感 +request_.set("X-Remote-Addr", remote_ip_); +``` + +- Beast 的 `fields::erase/set` 对头名大小写不敏感,`set` 本身即覆盖全部同名字段;显式 `erase` 先行使意图自解释。 +- 头名 `X-Remote-Addr`:与 `X-Forwarded-For`/`X-Real-IP` 区分语义——**它不是转发链信息,而是本跳的 socket 事实**,任何代理都不得原样转发(见 §4.2 剥离配套)。 + +### 3.2 边缘情况 + +| 场景 | 行为 | +|------|------| +| 客户端伪造 `X-Remote-Addr`(任意大小写) | 被 erase,无效 | +| keep-alive 同连接第 N 个请求 | 每次解析后重新注入,值不变 | +| WS-upgrade 请求 | 同样注入(注入点在分流前),WS 握手回调可读取 | +| 请求体超限(413)/解析失败 | 不进 handleRead 成功分支,无注入,也无 handler 调用 | +| `remote_ip_` 为 `"unknown"` | 理论上 accept 后必被 setRemoteInfo 覆盖;兜底值仍可注入,不影响正确性 | + +### 3.3 性能 + +每请求一次 `erase` + `set`,复杂度 O(头字段数)(Beast fields 为线性表),实测开销可忽略;无堆分配之外的引入(`remote_ip_` 已存在)。 + +--- + +## 4. gateway 配套设计(A4 闭环) + +### 4.1 配置项 + +`gateway.ini` 新增(confs 下发,与现有配置两层加载一致): + +```ini +[Security] +# 是否信任 X-Forwarded-For 作为限流/审计的客户端 IP 键。 +# on = 模式 A(WAF 协作):WAF 覆写 XFF,可信(默认,保持现状) +# off = 模式 B(独立部署):XFF 客户端可伪造,改用 libhttp 注入的 X-Remote-Addr +trust_xff = on +``` + +环境变量覆盖:`TRUST_XFF=off`(与 `LISTEN_*`/`DB_*` 覆盖机制同路径)。**默认 on**,不改变现有生产(模式 A)行为。 + +### 4.2 代码改动(`gateway/src/main.cpp`,约 30 行) + +1. **代理剥离**: `is_special_header()` 增加 `x-remote-addr`(小写比较已就绪)——防止两件事:(a) 模式 A 下把"WAF 的 IP"泄露给下游;(b) 客户端伪造值随转发到达下游。 +2. **限流键抽取**: 新增 helper,替换 5 处限流点的 XFF 直读(login/register/send_code/reset_password/captcha_image): + +```cpp +/* 限流键:模式 A 取 WAF 注入的 XFF;模式 B(trust_xff=off)取 libhttp 注入的 + * 对端地址,客户端伪造 XFF 不再影响限流(审计 A4) */ +static std::string rate_limit_key(const http::request& req) { + auto it = req.base().find(_TRUST_XFF ? "X-Forwarded-For" : "X-Remote-Addr"); + if (it != req.base().end()) return std::string(it->value()); + return "direct"; +} +``` + +3. **配置加载**: `main()` 读取 `[Security] trust_xff`(默认 true)→ `_TRUST_XFF` 全局 bool;启动日志打印当前模式。 +4. **README**: 模式 B 的 A4 降级提示翻转为已根治(`trust_xff=off` 时按对端地址限流),并补充模式 B 配置示例。 + +### 4.3 行为矩阵 + +| 部署 | trust_xff | 限流键 | 伪造 XFF 效果 | +|------|-----------|--------|---------------| +| 模式 A(WAF) | on(默认) | XFF(WAF 覆写) | 无效(现状) | +| 模式 B(独立) | off | `X-Remote-Addr`(socket 对端) | **无效(新增)** | +| 模式 B(独立) | on | XFF(客户端控制) | 可绕过限流(现状,文档已警示) | + +--- + +## 5. 开发计划 + +| 步骤 | 文件 | 内容 | +|------|------|------| +| 1 | `libhttp/src/HttpServer_Request.cpp` | `handleRead` 成功分支注入 `X-Remote-Addr`(2 行 + 注释) | +| 2 | `libhttp/test_server.cpp` | 用例:handler 断言收到注入头;客户端自带伪造头被覆盖 | +| 3 | `libhttp/README.md` | 接口契约补充:`X-Remote-Addr` 由服务端注入、语义、禁止转发约定 | +| 4 | `gateway/src/main.cpp` | `is_special_header` 加 `x-remote-addr`;`rate_limit_key()` helper;5 处限流点替换;`_TRUST_XFF` 加载 | +| 5 | `confs/configs/gateway.ini` | `[Security] trust_xff = on`(含注释) | +| 6 | `gateway/README.md` | 模式 B 提示翻转 + 配置项文档 | +| 7 | `tests/blackbox/test_gateway.py` | E 段限流用例参数化:`trust_xff=off` 重启后伪造 XFF 不再重置限流桶 | + +**编译依赖**: gateway 需重链新版 `us_http`;core/dnfs2/fsys 无需重编(头注入对它们透明),但建议随版本统一重建。 + +## 6. 测试计划 + +| 测试 | 预期 | +|------|------| +| libhttp 单测:handler 读取 `X-Remote-Addr` | 等于连接对端 IP(127.0.0.1) | +| libhttp 单测:客户端发送 `x-remote-addr: 6.6.6.6` | handler 读到的是对端 IP,伪造值被覆盖 | +| gateway 回归(trust_xff 默认 on) | 现有 34 断言全绿(32 passed / 2 env-skipped) | +| gateway 模式 B 实测(trust_xff=off) | 同 XFF 连发 6 次 /login → 全部按对端 IP 计数;换伪造 XFF **不再**获得新桶(第 6 次起稳定 429) | +| 代理转发检查 | 下游收到的请求不含 `X-Remote-Addr`(用 static echo 或抓包验证) | + +## 7. 风险与兼容 + +| 风险 | 评估 | 缓解 | +|------|------|------| +| 下游泄露对端地址 | gateway 转发前剥离(§4.2-1);core/dnfs2/fsys 不做转发,无泄露面 | 剥离进 `is_special_header`,与 XFF 同机制 | +| 客户端伪造 | erase-then-set,任意大小写变体均被覆盖 | Beast fields 大小写不敏感保证 | +| 既有消费方兼容 | handler 签名不变,不读该头即无感 | 非破坏设计本身 | +| 模式 A 误配 trust_xff=off | 限流键变为 WAF 的 IP(127.0.0.1),全站共享一个桶 → 限流形同全局熔断 | 启动日志打印模式;README 配置矩阵警示 | + +## 8. 工作量估算 + +| 部分 | 规模 | +|------|------| +| libhttp(实现 + 单测 + README) | ~40 行 | +| gateway(helper + 5 处替换 + 配置 + 剥离) | ~35 行 | +| 配置/文档/黑盒用例 | ~50 行 | +| **合计** | 半天内(含构建与实测) | diff --git a/docs/security/gateway_SECURITY_AUDIT_20260809.md b/docs/security/gateway_SECURITY_AUDIT_20260809.md new file mode 100644 index 0000000..50770f7 --- /dev/null +++ b/docs/security/gateway_SECURITY_AUDIT_20260809.md @@ -0,0 +1,265 @@ +# gateway 模块安全审计报告(2026-08-09 增量复审) + +**审计日期**: 2026-08-09 +**审计范围**: `src/main.cpp`(v0.8.0,2526 行全量)、`inc/DbC.hpp`、`inc/LogC.hpp`、`inc/Conf.hpp`、`inc/ExpBase.hpp` +**基线文档**: [gateway_SECURITY_AUDIT_20260807.md](gateway_SECURITY_AUDIT_20260807.md)(P0 3/3 已修复、P1 8 项 56%、P2 6 项 33%) +**审计方式**: 全量代码走查 + 实机黑盒实证(重建二进制 → 启动 8443 → curl 探针 12 组 → 对照基线做增量发现) +**配套交付**: `tests/blackbox/test_gateway.py` 重写 + `helpers.py` 增强(见 §六) + +--- + +## 代码修复记录 + +**修复日期**: 2026-08-09(审计当日) + +| 编号 | 状态 | 代码位置 | 修复说明 | +|------|------|----------|----------| +| A1 | ✅ 已修复 | `src/main.cpp` 关闭流程 | 删除 else 分支重复的 `log_flush_thread.join()`(ready 时 async 已完成 join) | +| A2 | ✅ 已修复 | `handleProxy` 白名单检查 | `throw std::runtime_error("403")` → `THROW_EX_CUSTOM(HttpException, 2303, "Forbidden: target not allowed", 403, "*")` | +| A3 | ✅ 已修复 | `handleLogin`/`handleRegister`/`handleResetPassword` | 字段提取 `obj.at()` 包 try/catch,缺字段返回 400 | +| A5 | ✅ 已修复 | `is_special_header()` | 比较前统一转小写,小写 `x-forwarded-for` 等变体不再绕过剥离 | +| A6 | ✅ 已修复 | `handleRegister` CAPTCHA 校验 | `verify_captcha()`(内置存储)→ `verify_captcha_remote()`(captcha2),与 `/captcha/image` 渠道互通 | +| A7 | ✅ 已修复 | `src/main.cpp` 内置验证码渠道 | 整体移除:`POST /captcha` 路由、`handleCaptcha`、`generate_captcha_text`/`verify_captcha`、`_CAPTCHA_STORE`/`_CAPTCHA_MTX`(净 -79 行);未注册路径按既有通配代理语义处理 | +| A8 | ✅ 已修复 | `wrapperHttp` HttpException 分支 | 客户端消息从 `e.what()` 改为 `e.message()`,`[Http][Error NNNN]` 前缀仅保留在服务端 stderr/集中日志 | +| A4 | 📝 文档化 | `README.md` 部署模式 B | 根因需 libhttp 向 handler 透传 TCP 对端地址(跨模块 API 变更),本轮以文档化闭环:README 模式 B 增加安全降级提示(伪造 XFF 可绕过限流,生产必须用模式 A) | + +### 修复验证 + +| 验证项 | 结果 | +|--------|------| +| 编译(make,g++ -std=c++20) | ✓ 通过(仅 LogC.hpp 既有 snprintf 警告) | +| 黑盒全量(test_gateway.py 34 断言) | ✓ **32 passed / 0 failed / 2 skipped**,锁定 A3 的两个红测试转绿 | +| A1 关闭路径 | ✓ `kill` 后 "已退出" 正常打印,进程 Done 退出,无 core dump(两轮构建均复核) | +| A2 | ✓ 代码路径确认(HttpException 经 wrapperHttp 映射 403;黑盒触达需有效 Token,待测试钩子) | +| A8 实证 | ✓ `POST /login {}` → `{"success":false,"message":"Bad_Request"}`,无 `[Http][Error 2002]` 前缀 | +| A7 实证 | ✓ `POST /captcha` 不再返回答案(落入通配代理语义,与其他未注册路径行为一致) | +| 黑盒全量回归(修复 A6/A7/A8 后) | ✓ 32 passed / 0 failed / 2 skipped,无回归 | + +--- + +## 一、审计结论摘要 + +本次为增量复审,基线已确认项不重复列出(复测见 §五)。新发现 8 项: + +| 等级 | 数量 | 编号 | +|------|------|------| +| 🔴 P0 | 0 | — | +| 🟠 P1 | 2 | A1(优雅关闭必崩溃)、A2(白名单拒绝返回 500 而非 403) | +| 🟡 P2 | 4 | A3(缺字段 500)、A4(XFF 伪造绕过限流)、A5(头名大小写架空 P1-1 修复)、A6(双 CAPTCHA 渠道不互通) | +| ⚪ P3 | 2 | A7(内置验证码接口明文给答案)、A8(内部错误码前缀外泄) | + +**未发现新的 P0**。Token 验签链(RSA + 过期 + jti 黑名单 + 版本号)、PBKDF2(10 万轮 + 常数时间比较)、fail-closed 下游语义、防用户枚举 dummy 校验等核心机制实现正确。 + +--- + +## 二、P1 详细发现 + +### A1 [P1] 优雅关闭以崩溃收场:`log_flush_thread` double-join + +**位置**: `src/main.cpp` L1066-1077 + +```cpp +std::thread log_flush_thread([]() { logs().shutdown(); }); +auto future = std::async(std::launch::async, &std::thread::join, &log_flush_thread); +if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { + log_flush_thread.detach(); +} else { + log_flush_thread.join(); // ← async 已完成 join,此处二次 join +} +``` + +**根因**: `std::async(std::launch::async, &std::thread::join, &t)` 已在独立线程中执行完 `join()`;`wait_for` 返回 ready 后 else 分支再次 `t.join()`,此时 `joinable()==false` → 抛 `std::system_error`(EINVAL)→ 无人捕获 → `std::terminate` → SIGABRT。 + +**实证**: +``` +$ kill +terminate called after throwing an instance of 'std::system_error' + what(): Invalid argument +[1]+ Aborted (core dumped) nohup ./build/gateway.out +``` +"已退出" 收尾日志不打印,进程以 core dump 结束。基线报告修复的"进程退出 bug"(0807 版)实际只修了一半:main 函数体能走完,但关闭路径最后一步仍崩溃。 + +**影响**: 每次正常关闭(SIGINT/SIGTERM)都产生 core dump;systemd 下触发不必要告警;关闭期日志是否落库无从保证。 + +**修复建议**: 删除 else 分支中重复的 `log_flush_thread.join()`(ready 即已 join 完毕)。 + +### A2 [P1] 白名单外 Target-Url 返回 500 而非文档宣称 403 + +**位置**: `src/main.cpp` L2401-2404(`handleProxy`) + +```cpp +if (!is_safe_target(target)) { + throw std::runtime_error("403"); // 意图返回 403 +} +``` + +**根因**: `std::runtime_error` 不是 `HttpException`,被 `wrapperHttp()`(L1288-1303)的 `std::exception` 兜底分支统一包装为 500 "Internal Server Error"。README 接口契约明确写"目标不在白名单 → 403"。 + +**实证局限**: 白名单检查在 Token 验签之后,无有效 Token 时只能走到 401,黑盒无法触达;但代码路径确凿(`runtime_error` 必经 500 兜底)。待测试钩子落地后补黑盒断言。 + +**影响**: 客户端无法区分"目标被拒"与"服务器故障";监控告警误报 500。 + +**修复建议**: 改为 `THROW_EX_CUSTOM(HttpException, 2303, "Forbidden", 403, "*")`,与 L2370 验签失败的风格一致。 + +--- + +## 三、P2 详细发现 + +### A3 [P2] 缺失 JSON 字段 → 500 而非 400 + +**位置**: `src/main.cpp` L1373-1374(`handleLogin`,同型问题遍布 `handleRegister`/`handleResetPassword`/`handleSendEmailCode` 等) + +```cpp +auto& obj = json.as_object(); +std::string username = obj.at("username").as_string().c_str(); // ← 在 try 块外 +std::string password = obj.at("password").as_string().c_str(); +``` + +**根因**: JSON 语法解析有 try/catch(解析失败 → 400),但随后的字段提取 `obj.at()` 在 try 之外,`std::out_of_range` 直达 `wrapperHttp` 兜底 → 500。 + +**实证**: `POST /login` body `{}` → **500**(应为 400 Bad Request)。已由新版黑盒测试 A5/A8 两个用例锁定(当前红,修复后转绿)。 + +**修复建议**: 各 handler 的字段提取统一包进 try/catch,缺字段返回 400;或提供 `get_or_throw_400(obj, key)` 辅助函数。 + +### A4 [P2] 独立部署模式 B 下限流可被 XFF 伪造绕过 + +**位置**: `src/main.cpp` L1353-1359(`handleLogin`,`handleRegister`/`handleCaptchaImage` 同型) + +```cpp +std::string client_ip = req.base()["X-Forwarded-For"]; // 直连模式下即客户端伪造值 +if (client_ip.empty()) client_ip = req.remote_ip(); +``` + +**根因**: 模式 A(WAF 前置)中 WAF 会覆写 XFF,此逻辑安全;模式 B(独立部署)中 XFF 完全由客户端控制,每个伪造 IP 都是独立限流桶 → 限流失效。基线报告 P1-1 修复的是"向下游注入可信 XFF",限流侧未覆盖。 + +**实证**: +``` +XFF=10.1.1.1 连续 6 次 POST /login → 第 6 次 429 +XFF=10.1.1.2 立即再发 → 400(未限流,新桶) +``` + +**影响**: 登录爆破/注册滥用防护在独立部署下可绕过(每次请求换一个 XFF 即无上限)。 + +**修复建议**: 二选一——(a) 配置项声明部署模式,模式 B 下限流 key 只用 `remote_ip()`;(b) README 明确"独立部署时安全降级:限流不可用"并建议仅在 WAF 后使用。 + +### A5 [P2] `is_special_header` 大小写敏感,伪造 XFF 绕过剥离,架空 P1-1 修复 + +**位置**: `src/main.cpp` L2464-2476 + +```cpp +static bool is_special_header(const std::string& name) { + return name == "Host" || name == "connection" || name == "Accept-Encoding" || + name == "X-Forwarded-For" || name == "X-Real-IP" || name == "Forwarded"; +} +``` + +**根因**: Boost.Beast 的 `field.name_string()` 保留线上原始大小写。客户端发送小写 `x-forwarded-for: 6.6.6.6` 时不匹配 `"X-Forwarded-For"`,不被剥离;gateway 随后注入可信 XFF,下游收到**两个** XFF 头——若下游取第一个(常见实现),攻击者伪造值生效,基线 P1-1"注入可信 XFF"的修复被架空。 + +**实证**: 代码级确认(`name_string()` 行为见 Beast 文档;代理剥离逻辑 L2410-2420 逐头比对)。 + +**修复建议**: 比较前统一转小写(`boost::beast::iequals` 或手动 lowercase 后查集合),并将 `connection`/`keep-alive`/`transfer-encoding` 等逐跳头一并规范化。 + +### A6 [P2] 注册/登录 CAPTCHA 双渠道不互通,README 与实现不符 + +**位置**: `src/main.cpp` L1954(`handleRegister`)vs L1845-1887(`handleCaptchaImage`) + +**根因**: 注册接口用 `verify_captcha()` 校验**内置** `_CAPTCHA_STORE`(内置 `GET/POST /captcha` 接口签发的 32 位 token);而前端实际通过 `/captcha/image` 代理拿的是 **captcha2** 签发的 64 位 token。两个存储互不通信——前端走完图形验证码流程后,注册提交 captcha_token 永远 invalid。`/login` 走的是远端 `verify_captcha_remote()`(captcha2),与 `/captcha/image` 配套,正常。 + +**实证**: 不带 captcha 字段 `POST /login` → 400 Bad_Request(README 称"可选",代码实为强制)。 + +**影响**: 注册流程的 CAPTCHA 环节对真实前端不可用(目前靠"不提交则跳过"绕过——即注册 CAPTCHA 形同虚设);文档误导。 + +**修复建议**: 统一走 `verify_captcha_remote()`;废弃内置验证码接口(见 A7);README 更新为"登录强制 CAPTCHA"。 + +--- + +## 四、P3 详细发现 + +### A7 [P3] 内置 `POST /captcha` 明文返回答案,人机验证无效化 + +**位置**: `src/main.cpp` 内置验证码接口(`_CAPTCHA_STORE` 配套路由) + +**实证**: +``` +$ curl -X POST :8443/captcha +{"token":"...","text":"AmM2U"} ← 答案明文随 token 一起返回 +``` + +**叠加问题**: 该接口无速率限制、`_CAPTCHA_STORE` 无容量上限(内存 DoS 面)。 + +**修复建议**: 直接废弃内置验证码接口与 `_CAPTCHA_STORE`(captcha2 已全面接管),删除路由。 + +### A8 [P3] 错误消息携带内部错误码前缀外泄 + +**实证**: 错误响应体形如 `[Http][Error 2002] Bad_Request` —— 内部模块名与错误码编号随 4xx 返回客户端。 + +**评估**: 不构成路径/类型泄露(P0-2/P0-3 的脱敏基线未违反,16 条错误样本回归测试通过),但为攻击者提供内部结构测绘素材,且污染前端可直接展示的消息。 + +**修复建议**: `wrapperHttp` 返回客户端前剥离 `[模块][Error NNNN]` 前缀(完整信息保留在服务端日志)。 + +--- + +## 五、基线遗留项复测 + +| 编号 | 项 | 复测结果 | +|------|----|----------| +| P1-1 | 伪造 XFF 注入下游 | 修复已生效(标准大小写头被剥离),但**被 A5 架空** | +| P2-3 | CORS 反射任意 Origin | ❌ 仍存在:`Origin: https://evil.example.com` 被原样反射进 ACAO | +| — | 路径遍历防护 | ✅ `..`、`%2e`、`%2f`、`%5c` 四种变体全部 400 | +| — | `/whoami` 无/伪造 Token | ✅ 恒 `{login:false}`,不泄露验签细节 | +| — | `/admin/verify` fail-closed | ✅ 无/伪造 Token 均 401 | +| — | `/captcha/image` 代理 | ✅ 200 + image/png + 64 位 hex token + `no-store` + 限流 10/min/IP | +| — | 默认代理下游宕机语义 | ⚠️ static 宕机时默认路由 500(环境语义,非缺陷) | + +--- + +## 六、黑盒测试补充(已落地) + +### 6.1 测试套件脱节结论 + +- **test_gateway.py 旧版 10 场景已失效**:v0.8.0 后 /login 强制远端 CAPTCHA、/register 强制 email_code,无 OCR/收信能力的黑盒 happy path 全断;且无 db 清理,反复运行污染 Users 表 +- **test_captcha.py / test_smtp.py 形同虚设**:用 HTTP 测 pymsg/TCP 服务(captcha2:8009、smtp:8008),captcha 用例期望 32 位 token(captcha2 实为 64 位) +- **可测试性根因**:认证 happy path 需要测试钩子——方案 a:captcha2/smtp 提供测试模式(固定验证码答案);方案 b:`gateway.ini` 增加 `[Security] captcha_required/email_code_required` 开关 + +### 6.2 本轮交付(P0 部分,无需钩子) + +**`tests/blackbox/helpers.py` 增强(+48 行)**: + +| 工具 | 用途 | +|------|------| +| `random_xff()` | 随机 XFF 隔离限流桶(利用 A4 行为,避免限流用例互斥) | +| `assert_no_internal_leak()` | 错误响应脱敏回归(P0-2/P0-3 基线:无源码路径/C++ 类型名) | +| `db_execute()` | pymsg 调 db 做测试数据准备/清理 | + +**`tests/blackbox/test_gateway.py` 重写(34 断言 6 段)**: + +| 段 | 内容 | 断言数 | +|----|------|--------| +| A | 输入校验新契约(强制 captcha/email_code、fail-closed 语义) | 8 | +| B | `/whoami`、`/admin/verify`(含伪造 Token) | 4 | +| C | `/captcha/image` 代理(200/PNG/64 位 token/no-store) | 4 | +| D | 无 Token 401 + 路径遍历矩阵(原始请求行,`%2e/%2f/%5c` 变体) | 5 | +| E | 限流全谱:登录 6th→429、注册 4th→429、发码 6th→429、验证码 11th→429、换 XFF 隔离 | 9 | +| F | CORS 反射现状固化(P2-3 修复后翻转断言)+ 16 条错误样本脱敏回归 | 2 | +| 尾 | 测试用户清理(db `like` 删除,旧版 `test_%` 污染仅报告) | 2 | + +**实测结果**(gateway 实跑): `34 tests: 30 passed, 2 failed, 2 skipped` + +- 2 个 FAIL 为刻意锁定 A3 的红测试(缺字段断言 400,当前实现 500),修复后自然转绿 +- 2 个 SKIP 为环境性(static 未运行;本地 db 无 Users 表——**本环境下注册写库必 500**,跑 P1 认证链路测试前需先执行 `db/create_sqlite3.sql` 建表) + +### 6.3 后续清单(需钩子,P1/P2) + +- 完整旅程(captcha → send_code → register → login → whoami → logout)、Cookie 链路(Set-Cookie 属性、代理注入 Authorization)、jti/版本号吊销、refresh_token、白名单 403(A2 转绿)、防枚举时序 +- test_captcha.py / test_smtp.py 重写为 pymsg 直连 + +--- + +## 七、修复建议优先级 + +| 优先级 | 项 | 说明 | +|--------|----|------| +| P1 | A1、A2 | double-join 删 3 行;A2 改抛 HttpException,均为小改动 | +| P2 | A3、A5、A6、A4 | A3 修复后两个红测试转绿;A5 头名归一化;A6 统一走 captcha2;A4 配置或文档化 | +| P3 | A7、A8 | 废弃内置验证码接口;错误消息剥离内部前缀 | +| 配套 | 测试钩子 | captcha2/smtp 测试模式或 gateway.ini 开关,解锁 P1 测试清单 | diff --git a/tests/blackbox/README.md b/tests/blackbox/README.md index 8ff3e17..f099873 100644 --- a/tests/blackbox/README.md +++ b/tests/blackbox/README.md @@ -17,7 +17,7 @@ tests/blackbox/ ├── config.py # 服务地址与端口映射 ├── helpers.py # HTTP/TCP 请求封装 + 断言工具 + 计数器 ├── test_runner.py # 统一入口,--url 自动识别目标服务 -├── test_gateway.py # 用户注册 → 登录 → Token → 代理转发 +├── test_gateway.py # 输入校验/Token契约/代理路径安全/限流/CORS(v0.8.0 契约,认证链路待测试钩子) ├── test_captcha.py # 获取验证码 → 校验 → 一次性消费 ├── test_smtp.py # 发送邮箱验证码 → 校验 ├── test_dnfs.py # 创建笔记 → 查询 → 更新 → 删除(含内容审核) diff --git a/tests/blackbox/helpers.py b/tests/blackbox/helpers.py index b4ba71e..d1352bd 100644 --- a/tests/blackbox/helpers.py +++ b/tests/blackbox/helpers.py @@ -7,6 +7,7 @@ micro_servers 黑盒测试 — 共享工具模块。 import http.client import json +import random import socket import struct import time @@ -326,3 +327,50 @@ def check_service(url: str, timeout: float = 8.0) -> tuple: return False, f"网络错误: {e.strerror if hasattr(e, 'strerror') else str(e)}" except Exception as e: return False, str(e) + + +# --------------------------------------------------------------------------- +# gateway 测试专用工具 +# --------------------------------------------------------------------------- + +def random_xff() -> str: + """生成随机 X-Forwarded-For 值,用于隔离 gateway 的 IP 级限流桶。 + + 背景:gateway 直连模式下信任客户端 XFF 头(审计发现 A4),每个测试场景 + 使用独立随机 XFF,可避免限流用例之间互相消耗配额(如登录 5 次/5 分钟)。 + """ + return f"10.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}" + + +# 响应体泄露特征:源码文件名/路径/C++ 类型签名(P0-2/P0-3 脱敏回归基线) +_LEAK_PATTERNS = ("main.cpp", ".hpp", ".cpp:", "/opt/", "boost::", "std::", + "source_location", "typeid", "__cxa") + + +def assert_no_internal_leak(body: str, name: str, counter: Counter): + """断言响应体不含内部实现信息(源码路径/C++ 类型名等,P0-2/P0-3 回归)。""" + hits = [p for p in _LEAK_PATTERNS if p in body] + if not hits: + print(f" [PASS] {name}") + counter.add_pass() + return True + else: + print(f" [FAIL] {name}: 响应泄露内部信息 {hits}") + counter.add_fail(name, f"leak patterns {hits}") + return False + + +def db_execute(action: str, table: str, *, where: dict = None, + values: dict = None, host: str = "127.0.0.1", + port: int = 8005, timeout: float = 10.0) -> dict: + """通过 pymsg 调 db execute 路由(测试数据准备/清理用)。 + + 返回 db 完整响应 {"success": ..., "data": ...};db 不可达时 + 返回 {"success": False, "error": ...}(PymsgClient 内部兜底)。 + """ + payload = {"_route": "execute", "action": action, "table": table} + if where is not None: + payload["where"] = where + if values is not None: + payload["values"] = values + return PymsgClient(host, port, "test_blackbox").call(payload, timeout) diff --git a/tests/blackbox/test_gateway.py b/tests/blackbox/test_gateway.py index f9d06e6..7e00b7d 100644 --- a/tests/blackbox/test_gateway.py +++ b/tests/blackbox/test_gateway.py @@ -1,211 +1,381 @@ """ -gateway 黑盒用户模拟测试 — 注册 → 登录 → Token → 代理转发。 +gateway 黑盒用户模拟测试(v2 — 对齐 gateway v0.8.0 契约) -用户旅程: - 1. 注册新账号(含密码强度校验、邮箱格式校验) - 2. 登录获取 Token - 3. Token 用于代理访问后端服务 - 4. 重复注册(应失败) - 5. 错误密码登录(应失败) - 6. 无效 Token 代理(应拒绝) - 7. 密码强度不足(应拒绝) - 8. 邮箱格式错误(应拒绝) +覆盖范围(P0,无需测试钩子): + A. 输入校验与错误语义(v0.8.0 新契约:/login 强制 CAPTCHA、/register 强制 email_code) + B. /whoami 与 /admin/verify(fail-closed 契约) + C. /captcha/image 代理路由(captcha2 在线时) + D. 反向代理与路径安全(路径遍历矩阵,含 URL 编码变体) + E. 速率限制(随机 XFF 隔离限流桶,避免用例互斥) + F. CORS 预检现状固化 + 错误响应脱敏回归(P0-2/P0-3) + +已知待修复缺陷(断言目标行为,gateway 修复前会 FAIL): + - A3: 缺失 JSON 字段 → 当前 500,应为 400(场景 A5/A8) + - A2: 白名单外 Target-Url → 当前 500,应为 403(需有效 Token 才能触达,待测试钩子) + +认证 happy path(注册/登录/Token 生命周期/Cookie/jti 吊销/refresh)依赖 +captcha2/smtp 测试模式(固定验证码答案钩子),落地前黑盒无法覆盖(P1 清单)。 + +依赖说明: + - gateway 必须运行,否则全部 SKIP + - captcha2 (pymsg:8009) 不在线时 C/E5 段 SKIP(其余用例不受影响) + - static (:9200) 不在线时 D3 段 SKIP + - smtp 不在线不影响本文件(相关用例均为 fail-closed 400 路径) """ -import json +import http.client import random +import re import string -import time +import urllib.parse from helpers import ( - http_get, http_post, try_parse_json, - assert_eq, assert_status, assert_true, assert_json_key, - Counter, check_service, + http_get, http_post, http_request, try_parse_json, + assert_eq, assert_true, assert_no_internal_leak, + skip, Counter, check_service, check_tcp_service, + random_xff, db_execute, ) -from config import get_url +from config import SERVICES, get_url GATEWAY_URL = get_url("gateway") STATIC_URL = get_url("static") +# 本测试文件使用的账号前缀(P0 用例不会真正创建用户,清理为防御性执行) +TEST_USER_PREFIX = "testgw_" + def _random_email(): - """生成随机测试邮箱。""" + """生成随机测试邮箱(带本文件专属前缀,便于清理)。""" suffix = ''.join(random.choices(string.ascii_lowercase, k=8)) - return f"test_{suffix}@example.com" + return f"{TEST_USER_PREFIX}{suffix}@example.com" -def _random_password(): - """生成符合强度要求的随机密码。""" - chars = string.ascii_letters + string.digits + "!@#$%" - return ''.join(random.choices(chars, k=12)) +def _strong_password(): + """生成必过强度校验的密码(含大写/小写/数字/特殊四类)。""" + body = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + return f"Gw{body}!1" + + +def _raw_get_status(host: str, port: int, raw_path: str, headers: dict = None): + """发送原始 GET(不做任何路径规范化),返回 (status, body)。 + + urllib/curl 默认会折叠 ".." 路径段,路径遍历测试必须用原始请求行。 + """ + conn = http.client.HTTPConnection(host, port, timeout=10) + try: + conn.putrequest("GET", raw_path, skip_accept_encoding=True) + for k, v in (headers or {}).items(): + conn.putheader(k, v) + conn.endheaders() + resp = conn.getresponse() + body = resp.read().decode("utf-8", errors="replace") + return resp.status, body + finally: + conn.close() def run(url: str = None): """gateway 用户模拟测试入口。返回 (passed, failed, skipped)。""" gw = url or GATEWAY_URL c = Counter() + err_bodies = [] # 收集错误响应体,末尾统一做脱敏回归(F2) - print(f"\n--- gateway 用户注册与登录流程 ---\n") + print(f"\n--- gateway 输入校验/Token契约/代理/限流 测试 ---\n") + + parsed = urllib.parse.urlparse(gw) + gw_host, gw_port = parsed.hostname, parsed.port or 80 # ------------------------------------------------------------------ - # 0. 检查服务可用性 + # 0. 依赖可用性探测 # ------------------------------------------------------------------ if not check_service(gw)[0]: print(" gateway 服务不可达,跳过所有测试") c.add_skip() return c.passed, c.failed, c.skipped - email = _random_email() - password = _random_password() - token = None + cap_svc = SERVICES["captcha"] + captcha_up = check_tcp_service(cap_svc["host"], cap_svc["port"])[0] + if not captcha_up: + print(" [WARN] captcha 服务不可达,/captcha/image 相关用例将 SKIP") - # ------------------------------------------------------------------ - # 场景 1: 注册 — 密码强度不足 - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/register", { - "username": email, - "password": "123", - }) + static_up = check_service(STATIC_URL)[0] + if not static_up: + print(" [WARN] static 服务不可达,默认代理转发用例将 SKIP") + + # ================================================================== + # A. 输入校验与错误语义(v0.8.0 新契约) + # ================================================================== + print(" [A] 输入校验与错误语义") + + # A1: 注册-密码太短 → 401(密码强度校验先于邮箱验证码校验) + status, body, _ = http_post( + f"{gw}/register", + {"username": _random_email(), "password": "123"}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) assert_eq(status, 401, "注册-密码太短 (401)", c) - data = try_parse_json(body) - assert_true( - data is not None and not data.get("success"), - "注册-密码太短-拒绝", c, - ) - # ------------------------------------------------------------------ - # 场景 2: 注册 — 邮箱格式错误 - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/register", { - "username": "not-an-email", - "password": _random_password(), - }) + # A2: 注册-邮箱格式错误 → 400(须先过密码强度校验) + status, body, _ = http_post( + f"{gw}/register", + {"username": "not-an-email", "password": _strong_password()}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) assert_eq(status, 400, "注册-邮箱格式错误 (400)", c) + + # A3: 注册-缺 email_code → 400(v0.8.0 强制邮箱验证码) + status, body, _ = http_post( + f"{gw}/register", + {"username": _random_email(), "password": _strong_password()}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "注册-缺email_code (400)", c) + + # A4: 注册-错误 email_code → 400(smtp 不在线时 fail-closed 同为 400) + status, body, _ = http_post( + f"{gw}/register", + {"username": _random_email(), "password": _strong_password(), + "email_code": "000000"}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "注册-错误email_code (400)", c) + + # A5: 登录-空 JSON → 400(审计 A3:当前实现返回 500,修复前 FAIL) + status, body, _ = http_post( + f"{gw}/login", {}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "登录-缺字段 (400) [A3: 当前500,待修复]", c) + + # A6: 登录-缺 captcha 字段 → 400(v0.8.0 强制 CAPTCHA) + status, body, _ = http_post( + f"{gw}/login", + {"username": _random_email(), "password": _strong_password()}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "登录-缺captcha (400)", c) + + # A7: 登录-错误 captcha → 400(captcha 不可用 fail-closed 同为 400) + status, body, _ = http_post( + f"{gw}/login", + {"username": _random_email(), "password": _strong_password(), + "captcha_token": "0" * 64, "captcha_answer": "AAAAA"}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "登录-错误captcha (400)", c) + + # A8: 注册-空 JSON → 400(审计 A3:当前实现返回 500,修复前 FAIL) + status, body, _ = http_post( + f"{gw}/register", {}, + headers={"X-Forwarded-For": random_xff()}) + err_bodies.append(body) + assert_eq(status, 400, "注册-缺字段 (400) [A3: 当前500,待修复]", c) + + # ================================================================== + # B. /whoami 与 /admin/verify(fail-closed 契约) + # ================================================================== + print(" [B] whoami / admin.verify") + + # B1: whoami 无 Token → 200 + {"login": false} + status, body, _ = http_post(f"{gw}/whoami") data = try_parse_json(body) assert_true( - data is not None and not data.get("success"), - "注册-邮箱格式错误-拒绝", c, - ) + status == 200 and data is not None and data.get("login") is False, + "whoami-无Token (200, login:false)", c, f"status={status} body={body[:80]}") - # ------------------------------------------------------------------ - # 场景 3: 注册 — 成功 - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/register", { - "username": email, - "password": password, - }) - assert_eq(status, 200, "注册-成功 (200)", c) + # B2: whoami 伪造 Token → 200 + {"login": false}(不泄露验签细节) + status, body, _ = http_post( + f"{gw}/whoami", headers={"Authorization": f"Bearer {'A'*40}_{'B'*40}"}) data = try_parse_json(body) assert_true( - data is not None and data.get("success") is True, - "注册-返回 success=true", c, - ) + status == 200 and data is not None and data.get("login") is False, + "whoami-伪造Token (login:false)", c, f"status={status} body={body[:80]}") - # ------------------------------------------------------------------ - # 场景 4: 注册 — 重复用户名 - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/register", { - "username": email, - "password": password, - }) - # 期望注册失败(用户名已存在) - assert_true( - status != 200 or (try_parse_json(body) or {}).get("success") is not True, - "注册-重复用户名-拒绝", c, - f"status={status}, body={body[:100]}", - ) - - # ------------------------------------------------------------------ - # 场景 5: 登录 — 错误密码 - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/login", { - "username": email, - "password": "WrongPassword1", - }) - assert_eq(status, 401, "登录-错误密码 (401)", c) + # B3: admin/verify 无 Token → 401 fail-closed + status, body, _ = http_post(f"{gw}/admin/verify") data = try_parse_json(body) assert_true( - data is not None and not data.get("success"), - "登录-错误密码-拒绝", c, - ) + status == 401 and data is not None and data.get("success") is False, + "admin/verify-无Token (401)", c, f"status={status}") - # ------------------------------------------------------------------ - # 场景 6: 登录 — 成功获取 Token - # ------------------------------------------------------------------ - status, body, _ = http_post(f"{gw}/login", { - "username": email, - "password": password, - }) - assert_eq(status, 200, "登录-成功 (200)", c) + # B4: admin/verify 伪造 Token → 401 + status, body, _ = http_post( + f"{gw}/admin/verify", + headers={"Authorization": f"Bearer {'A'*40}_{'B'*40}"}) data = try_parse_json(body) assert_true( - data is not None and data.get("success") is True, - "登录-返回 success=true", c, - ) - token_data = (data or {}).get("data", {}) - token = token_data.get("token", "") - assert_true( - len(token) > 20 if token else False, - "登录-Token 不为空", c, - ) + status == 401 and data is not None and data.get("success") is False, + "admin/verify-伪造Token (401)", c, f"status={status}") - # ------------------------------------------------------------------ - # 场景 7: 代理 — 使用 Token 访问静态服务 - # ------------------------------------------------------------------ - if token: + # ================================================================== + # C. /captcha/image 代理路由(captcha2 在线时) + # ================================================================== + print(" [C] /captcha/image 代理") + if captcha_up: status, body, headers = http_get( - f"{gw}/", - headers={ - "Authorization": f"Bearer {token}", - "Target-Url": STATIC_URL, - }, - ) + f"{gw}/captcha/image", + headers={"X-Forwarded-For": random_xff()}) + assert_eq(status, 200, "captcha/image-代理 (200)", c) + content_type = headers.get("Content-Type", headers.get("content-type", "")) + assert_true("image/png" in content_type, + "captcha/image-Content-Type(png)", c, f"ct={content_type}") + cap_token = headers.get("X-Captcha-Token", headers.get("x-captcha-token", "")) assert_true( - status in (200, 302, 301), - "代理-GET静态首页", c, - f"status={status}", - ) + bool(re.fullmatch(r"[0-9a-f]{64}", cap_token)), + "captcha/image-Token(64位hex)", c, f"token={cap_token[:16]}...") + cache_ctl = headers.get("Cache-Control", headers.get("cache-control", "")) + assert_true("no-store" in cache_ctl, + "captcha/image-禁缓存(no-store)", c, f"cc={cache_ctl}") + else: + skip("captcha/image 代理", "captcha 服务不可达", c) - # ------------------------------------------------------------------ - # 场景 8: 代理 — 无 Token 应拒绝 - # ------------------------------------------------------------------ - status, body, _ = http_get( - f"{gw}/some-path", - headers={"Target-Url": STATIC_URL}, - ) - # 缺少 Token 应返回 400/401 + # ================================================================== + # D. 反向代理与路径安全 + # ================================================================== + print(" [D] 反向代理与路径安全") + + # D1: 显式 Target-Url + 无 Token → 401(Token 校验先于转发,不依赖 static) + status, body, _ = http_get(f"{gw}/some-path", + headers={"Target-Url": STATIC_URL}) + err_bodies.append(body) + assert_eq(status, 401, "代理-无Token拒绝 (401)", c) + + # D2: 路径遍历矩阵 → 400(路径校验先于 Token 校验;原始请求行防客户端折叠) + traversal_paths = [ + "/../../../etc/passwd", # 明文 .. + "/%2e%2e/%2e%2e/etc/passwd", # URL 编码 . + "/%2f..%2f..%2fetc", # 编码 / + "/%5c..%5cwindows", # 编码反斜杠 + ] + for p in traversal_paths: + status, body = _raw_get_status(gw_host, gw_port, p, + headers={"Target-Url": STATIC_URL}) + err_bodies.append(body) + assert_eq(status, 400, f"代理-路径遍历拒绝 {p[:24]} (400)", c) + + # D3: 默认代理(无 Target-Url,公开目标不需 Token) + if static_up: + status, body, _ = http_get(f"{gw}/") + assert_true(status in (200, 301, 302), + "代理-默认路由(static)", c, f"status={status}") + else: + skip("代理-默认路由", "static 服务不可达", c) + + # ================================================================== + # E. 速率限制(随机 XFF 隔离限流桶) + # ================================================================== + print(" [E] 速率限制") + + # E1: 登录限流 5 次/5 分钟/IP —— 同 XFF 前 5 次 400,第 6 次 429 + xff = random_xff() + results = [] + for _ in range(6): + status, body, _ = http_post( + f"{gw}/login", + {"username": _random_email(), "password": _strong_password()}, + headers={"X-Forwarded-For": xff}) + results.append(status) + err_bodies.append(body) + assert_true(all(s == 400 for s in results[:5]), + "限流-登录前5次放行(400)", c, f"results={results}") + assert_eq(results[5], 429, "限流-登录第6次 (429)", c) + + # E2: 限流桶隔离 —— 换 XFF 后不再 429(固化审计 A4:直连模式 XFF 客户端可控) + status, _, _ = http_post( + f"{gw}/login", + {"username": _random_email(), "password": _strong_password()}, + headers={"X-Forwarded-For": random_xff()}) + assert_eq(status, 400, "限流-换XFF重置桶 (400≠429)", c) + + # E3: 注册限流 3 次/10 分钟/IP —— 前 3 次 400,第 4 次 429 + xff = random_xff() + results = [] + for _ in range(4): + status, body, _ = http_post( + f"{gw}/register", + {"username": _random_email(), "password": _strong_password()}, + headers={"X-Forwarded-For": xff}) + results.append(status) + err_bodies.append(body) + assert_true(all(s == 400 for s in results[:3]), + "限流-注册前3次放行(400)", c, f"results={results}") + assert_eq(results[3], 429, "限流-注册第4次 (429)", c) + + # E4: 发码限流 5 次/10 分钟/IP —— 缺 captcha 每次 400,第 6 次 429 + xff = random_xff() + results = [] + for _ in range(6): + status, body, _ = http_post( + f"{gw}/register/send_code", + {"email": _random_email()}, + headers={"X-Forwarded-For": xff}) + results.append(status) + err_bodies.append(body) + assert_true(all(s == 400 for s in results[:5]), + "限流-发码前5次放行(400)", c, f"results={results}") + assert_eq(results[5], 429, "限流-发码第6次 (429)", c) + + # E5: /captcha/image 限流 10 次/分钟/IP —— 前 10 次 200,第 11 次 429 + # (captcha2 全局限流 600/分钟,不干扰本用例) + if captcha_up: + xff = random_xff() + results = [] + for _ in range(11): + status, _, _ = http_get(f"{gw}/captcha/image", + headers={"X-Forwarded-For": xff}) + results.append(status) + assert_true(all(s == 200 for s in results[:10]), + "限流-验证码前10次放行(200)", c, f"results={results}") + assert_eq(results[10], 429, "限流-验证码第11次 (429)", c) + else: + skip("限流-验证码", "captcha 服务不可达", c) + + # ================================================================== + # F. CORS 预检现状 + 错误响应脱敏回归 + # ================================================================== + print(" [F] CORS 与错误脱敏") + + # F1: OPTIONS 预检 —— 固化当前行为:反射任意 Origin(P2-3 未修复)。 + # TODO(P2-3): 白名单生效后应翻转断言为「白名单外 Origin 无 ACAO 头」。 + status, _, headers = http_request( + "OPTIONS", f"{gw}/login", + headers={"Origin": "https://test-orig.example", + "Access-Control-Request-Method": "POST"}) + acao = headers.get("Access-Control-Allow-Origin", + headers.get("access-control-allow-origin", "")) assert_true( - status in (400, 401, 403), - "代理-无Token拒绝", c, - f"status={status}", - ) + acao == "https://test-orig.example", + "CORS-预检反射Origin(现状,P2-3待修)", c, f"ACAO={acao!r} status={status}") - # ------------------------------------------------------------------ - # 场景 9: 代理 — 无效路径(路径遍历) - # ------------------------------------------------------------------ - if token: - status, body, _ = http_get( - f"{gw}/../../../etc/passwd", - headers={ - "Authorization": f"Bearer {token}", - "Target-Url": STATIC_URL, - }, - ) - assert_true( - status in (400, 403), - "代理-路径遍历拒绝", c, - f"status={status}", - ) + # F2: 错误响应脱敏 —— 所有收集到的 4xx/5xx 响应体不得含源码路径/类型名 + assert_no_internal_leak("\n".join(err_bodies), + f"错误响应脱敏(共{len(err_bodies)}条样本)", c) - # ------------------------------------------------------------------ - # 场景 10: 默认代理 (无 Target-Url,无需 Token) - # ------------------------------------------------------------------ - status, body, _ = http_get(f"{gw}/") - assert_true( - status in (200, 302, 301), - "代理-默认路由", c, - f"status={status}", - ) + # ================================================================== + # 测试后清理(P0 用例不创建用户,防御性执行;db 不可达则跳过) + # ================================================================== + db_svc = SERVICES["db"] + if check_tcp_service(db_svc["host"], db_svc["port"])[0]: + resp = db_execute( + "delete", "Users", + where={"username": {"like": f"{TEST_USER_PREFIX}%"}}, + host=db_svc["host"], port=db_svc["port"]) + if not resp.get("success") and "table not found" in str(resp.get("error", "")): + # 当前环境 db 无 Users 表(如本地开发库),清理无意义 + skip("清理-删除本测试用户记录", "当前环境无 Users 表", c) + else: + assert_true(resp.get("success") is True, + "清理-删除本测试用户记录", c, f"resp={str(resp)[:100]}") + # 旧版测试遗留污染(test_%@example.com)仅报告数量,不自动删除 + legacy = db_execute( + "select", "Users", + where={"username": {"like": "test_%@example.com"}}, + host=db_svc["host"], port=db_svc["port"]) + if legacy.get("success") and legacy.get("data"): + print(f" [WARN] 发现 {len(legacy['data'])} 条旧版测试遗留用户 " + f"(test_*@example.com),建议人工清理") + else: + skip("清理-测试用户", "db 服务不可达", c) # ------------------------------------------------------------------ # 汇总 From 407ce3c366ba481bc4e441fabeccbeffb63b6aa5 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 12:15:01 +0000 Subject: [PATCH 002/196] =?UTF-8?q?feat(ca):=20=E8=AE=A4=E8=AF=81=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=8C=96=20CA=20=E6=A8=A1=E5=9D=97=20v1.0.0=20+=20gat?= =?UTF-8?q?eway=20v0.9.0=20=E7=BA=AF=E8=BD=AC=E5=8F=91=E7=98=A6=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拓扑:waf:443 → ca:8443(认证+前置转发)→ gateway:8445(仅 loopback 纯转发), WAF upstream 不变,core/dnfs2/前端零改动。 ca v1.0.0(新模块): - 认证资产逐字搬迁自 gateway v0.8.0(9 条认证路由、Token 双轨吊销、 PBKDF2、限流、WS 代理鉴权、wrapperHttp、优雅关闭) - handleForward 前置转发层:固定上游 gateway:8445;无 Target-Url 公开直转, 有则本地验签后注入 X-Auth-User/X-Auth-Admin/Authorization(strip-then-set) - is_special_header 新增剥离 x-auth-user/x-auth-admin;上游不可达统一 502 - confs/configs/ca.ini 新增([Upstream] gateway + UPSTREAM_GATEWAY env) gateway v0.9.0(2470→约 560 行): - 删除全部认证代码,退居 127.0.0.1:8445 - 启动 loopback 强制自检(非 loopback 拒绝启动) - X-Auth-User 存在性不变量:显式 Target-Url 无该头一律 401 - gateway.ini 精简至 [Server]/[Proxy]/[Downstream db] 验收:黑盒 34 断言对 8443 原样全绿(32 passed/2 skipped,与基线一致); 探针实测伪造 X-Auth-User 经 CA 被剥离(echo 实证)、直连 8445 无头 401、 白名单外 403、gateway 宕机 CA 502。core/init.txt 加入 ca/_run.sh。 --- README.md | 48 +- ca/.gitignore | 74 + ca/Makefile | 19 + ca/README.md | 142 ++ ca/_run.sh | 10 + ca/_set.sh | 3 + ca/inc/Conf.hpp | 77 + ca/inc/DbC.hpp | 57 + ca/inc/ExpBase.hpp | 411 +++++ ca/inc/LocalTime.hpp | 71 + ca/inc/LogC.hpp | 206 +++ ca/src/main.cpp | 2463 +++++++++++++++++++++++++++++ confs/configs/ca.ini | 51 + confs/configs/gateway.ini | 49 +- core/init.txt | 1 + docs/reports/auth_service_plan.md | 548 ++++--- gateway/Makefile | 6 +- gateway/README.md | 282 +--- gateway/src/main.cpp | 1996 +---------------------- 19 files changed, 3991 insertions(+), 2523 deletions(-) create mode 100644 ca/.gitignore create mode 100644 ca/Makefile create mode 100644 ca/README.md create mode 100755 ca/_run.sh create mode 100755 ca/_set.sh create mode 100644 ca/inc/Conf.hpp create mode 100644 ca/inc/DbC.hpp create mode 100644 ca/inc/ExpBase.hpp create mode 100644 ca/inc/LocalTime.hpp create mode 100644 ca/inc/LogC.hpp create mode 100644 ca/src/main.cpp create mode 100644 confs/configs/ca.ini diff --git a/README.md b/README.md index 811f4f3..218b361 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # micro_servers 一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。 -`core` 作为进程托管服务负责拉起并守护各业务子进程,`redirect80` 将 80 端口流量重定向到 443,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。`db2` / `dnfs2` 分别为 `db` / `dnfs` 的 C++20 重实现,接口完全兼容可平替。此外包含 aibox 的终端前端 `BotOps`、独立的机器人硬件控制模块 `bot4cj`、嵌入式帧缓冲图形界面 `lci` 和嵌入式脚本引擎 `libtry`。 +`core` 作为进程托管服务负责拉起并守护各业务子进程,`redirect80` 将 80 端口流量重定向到 443,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`ca` 作为中央认证服务统一鉴权(waf → ca → gateway 前置拓扑),`gateway` 退化为纯转发反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。`db2` / `dnfs2` 分别为 `db` / `dnfs` 的 C++20 重实现,接口完全兼容可平替。此外包含 aibox 的终端前端 `BotOps`、独立的机器人硬件控制模块 `bot4cj`、嵌入式帧缓冲图形界面 `lci` 和嵌入式脚本引擎 `libtry`。 ## 架构总览 @@ -29,7 +29,8 @@ ┌─────┴──────┐ │ redirect80 │ :80 → :443(GET/HEAD 301 / 其余 308,重定向到 waf) └────────────┘ - 日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) + 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) + 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) ``` @@ -39,9 +40,9 @@ 1. 客户端若以 HTTP 访问 `:80`,由 `redirect80` 重定向到 HTTPS `:443`(GET/HEAD 返回 301,其余方法返回 308 保留 body)。 2. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。 -3. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 -4. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 -5. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 +3. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `ca:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。 +4. `ca` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。 +5. 其余请求携带 Token,经 `ca` 验签后注入 `X-Auth-User` 身份头转发到 `gateway:8445`(仅 loopback,不验签仅做存在性检查),再由 gateway 反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。 6. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。 7. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。 8. 各服务通过 `log_client.py` SDK 异步批量推送运行日志(pymsg/TCP 写入 db 的 `Logs` 表),实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。 @@ -52,15 +53,16 @@ |------|------|------|------| | [core](core/) | C++20 | 5200 | 进程托管服务(PMC),创建/列举/杀死/执行子进程,读取自启动列表守护业务进程,提供 HTTP 管理接口 | | [redirect80](redirect80/) | Python | 80 | HTTP→HTTPS 重定向服务,将 80 端口请求重定向到 443(GET/HEAD 301 / 其余 308),纯标准库实现 | -| [waf](waf/) | Go | 443 | WAF 网关(入口),终止 TLS、coraza 规则匹配(SecLang)、反向代理到 gateway,支持 SIGHUP 热加载规则 | -| [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 | +| [waf](waf/) | Go | 443 | WAF 网关(入口),终止 TLS、coraza 规则匹配(SecLang)、反向代理到 ca,支持 SIGHUP 热加载规则 | +| [ca](ca/) | C++20 | 8443 | 中央认证服务(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、身份头注入后前置转发到 gateway | +| [gateway](gateway/) | C++20 | 8445 | 应用层网关(仅 loopback),纯转发反向代理到内部服务,不验签(v0.9.0 起认证迁至 ca) | | [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 | | [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 | | [dnfs2](dnfs2/) | C++20 | 8006 | dnfs 的 C++20 重实现(libhttp + libmsg),接口与 Python 版完全兼容,修复 P0/P1 安全问题,可平替 | | [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) | | [db2](db2/) | C++20 | 8005 | db 的 C++20 重实现(libmsg + boost::json + libsqlite3),execute 路由/where 语法/响应格式完全对齐 Python 版,可平替 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | -| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 | +| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 ca 的 `GET /captcha/image` 代理给浏览器 | | [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env | | [confs](confs/) | Python(pymsg) | 8010 | 统一配置服务,集中存放并分发各服务配置文件(pymsg 路由 get/list/reload/health),内存缓存 + mtime 自动重载,路径遍历防护 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | @@ -73,29 +75,35 @@ - 可执行文件 `nidus.out`,作为 systemd 服务(`Nidus.service`)常驻运行(崩溃守护由 systemd 负责,core 自身不含自动重启逻辑)。 - 支持两种工作模式:命令行子系统调用(`--send/--exec/--list/--kill`)与 HTTP 伺服器模式(`--serve `)。 - 通过命名管道(Pipe)与消息队列(Mq)实现进程间通信。 -- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / gateway / captcha / aibox)。 +- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / ca / gateway / captcha / aibox)。 ### waf(WAF 网关) - 唯一对外入口,终止 TLS(`certs/full_chain.crt` / `certs/server.key`),监听 443 端口。 - 基于 [coraza](https://github.com/corazawaf/coraza) v3.3.0(ModSecurity SecLang 的 Go 实现),规则匹配命中后返回 403。 -- 未命中规则的请求以明文 HTTP 反向代理到 `gateway:8443`,透传 `X-Forwarded-For` / `X-Forwarded-Proto`。 +- 未命中规则的请求以明文 HTTP 反向代理到 `ca:8443`,透传 `X-Forwarded-For` / `X-Forwarded-Proto`。 - 规则库位于 [waf/rules/](waf/rules/),分两段:本地自定义(ID 100000-199999)与 OWASP CRS(ID 900000-999999,可选)。 - 极客操作:`kill -HUP ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 -### gateway(应用层网关 · v0.8.0) +### ca(中央认证服务 · v1.0.0) -- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。 -- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。 +- 内部 HTTP 服务,默认监听 127.0.0.1:8443(接管原 gateway 端口,WAF upstream 不变),不再承载 TLS(TLS 已由 WAF 终止)。 - **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。 -- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 -- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。 -- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。 -- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。 -- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。 -- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。 +- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 +- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效;jti 黑名单 + 用户版本号双轨吊销。 +- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;验签时无 `Authorization` 头则回退读 Cookie,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。 +- **前置转发**:无 `Target-Url` 为公开请求(剥身份头直转 gateway);有 `Target-Url` 为受保护请求(验签后注入 `X-Auth-User`/`X-Auth-Admin` 转发,strip-then-set 契约剥离客户端伪造值);上游不可达统一 502。 +- **CAPTCHA**:`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由;`/login` 强制 CAPTCHA。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发码 IP 级 + 邮箱级双窗口,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。 +- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立,无默认管理员账号,首位注册者即为管理员。 +- 详见 [ca/README.md](ca/README.md)。 + +### gateway(应用层网关 · v0.9.0) + +- 纯转发服务,仅监听 127.0.0.1:8445(loopback 启动强制自检),不再验签(认证已迁至 ca)。 +- **存在性不变量**:显式 `Target-Url` 请求必须携带 ca 注入的 `X-Auth-User` 头,缺失一律 401。 +- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中,缺省走 `Proxy.default`;`X-Auth-User`/`X-Auth-Admin`/`Authorization` 原样透传下游。 - 详见 [gateway/README.md](gateway/README.md)。 ### static(前端托管服务) diff --git a/ca/.gitignore b/ca/.gitignore new file mode 100644 index 0000000..8b604b6 --- /dev/null +++ b/ca/.gitignore @@ -0,0 +1,74 @@ +#PRJ +*.db +*.crt +*.key + +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Linker files +*.ilk + +# Debugger Files +*.pdb + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll +*.so.* + + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# Build directories +build/ +Build/ +build-*/ + +# CMake generated files +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +#Makefile +install_manifest.txt +compile_commands.json + +# Temporary files +*.tmp +*.log +*.bak +*.swp + +# vcpkg +vcpkg_installed/ + +# debug information files +*.dwo + +# test output & cache +Testing/ +.cache/ diff --git a/ca/Makefile b/ca/Makefile new file mode 100644 index 0000000..33aa32b --- /dev/null +++ b/ca/Makefile @@ -0,0 +1,19 @@ +VERSION := 1.0.0 +APP_NAME := ca +INSTL_DIR := /usr/local/bin +CPP := g++ +INCS := -I inc/ -I ../libmsg/inc +MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg +LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) +CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror +BUILD_DIR = build +$(shell mkdir -p $(BUILD_DIR)) + +#-------- +all: $(BUILD_DIR)/$(APP_NAME).out +$(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o + $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ +$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp + $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ +clear: + @rm -rvf build/ diff --git a/ca/README.md b/ca/README.md new file mode 100644 index 0000000..2fb1bbb --- /dev/null +++ b/ca/README.md @@ -0,0 +1,142 @@ +# ca + +UniServe Framework 的中央认证服务(Central Auth),C++20 实现,v1.0.0 起承接原 gateway v0.8.0 的全部认证职责。 + +## 拓扑定位(waf → ca → gateway 前置拓扑) + +``` +客户端 ──HTTPS──▶ waf:443 (终止 TLS + coraza 规则) ──HTTP──▶ ca:8443 (认证+前置转发) ──HTTP──▶ gateway:8445 (纯转发) ──▶ static:9200 / dnfs2:8006 / core:5200 +``` + +- CA 接管原 gateway 的 **8443** 端口,WAF upstream 配置不变(`waf/config.yaml` 仍指向 8443)。 +- gateway 退居 **8445**(仅 loopback),退化为纯转发服务,不再验签。 +- 验签在 CA 本地完成(RSA 密钥同进程),对受保护请求零网络跳。 +- 下游 core / dnfs2 / 前端 / WAF 全部零改动。 + +## 职责 + +- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 +- **Token 生命周期**:`/logout`(jti 吊销)、`/refresh_token`(宽限期刷新 + 次数上限)、`/whoami`、`/admin/verify`(供 core 等内部服务调用)。 +- **前置转发层**:通配 `GET/POST *` 把请求转发到唯一上游 gateway:8445: + - 无 `Target-Url` 头 = 公开请求:剥离伪造身份头(`X-Auth-User`/`X-Auth-Admin`)后直转。 + - 有 `Target-Url` 头 = 受保护请求:本地验签(RSA + 过期 + jti 黑名单 + 用户版本号),通过后注入 `X-Auth-User`(管理员追加 `X-Auth-Admin: 1`)与 `Authorization: Bearer` 再转发。 + - 上游不可达/超时统一返回 `502 Bad Gateway`(区别于 CA 自身故障的 500)。 +- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务。 +- **CAPTCHA 代理**:`GET /captcha/image` 代理 captcha2 签发图形验证码(PNG + `X-Captcha-Token` 响应头,10 次/分钟/IP,禁缓存)。 +- **邮箱验证码**:`/register/send_code`、`/reset/send_code` 发送 6 位验证码邮件(10 分钟有效);`/reset_password` 重置密码(防枚举恒成功 + 改密后版本号吊销全端 Token)。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 +- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(见审计 P2-3)。 + +## 身份头契约(strip-then-set) + +| 头 | 方向 | 语义 | +|----|------|------| +| `X-Auth-User` | CA → gateway → 下游 | 已认证用户名(邮箱)。客户端携带的同名单被 CA 剥离,仅 CA 注入的值可信 | +| `X-Auth-Admin: 1` | CA → gateway → 下游 | 仅当 Token `is_admin=true` 时注入 | +| `Authorization: Bearer ` | CA → gateway → 下游 | Token 来自 Cookie 时由 CA 补齐;来自客户端 Authorization 头时透传 | + +gateway 侧仅做**存在性不变量**检查(显式 `Target-Url` 请求必须携带 `X-Auth-User`,否则 401),不复验签名——信任边界是 loopback。 + +## 安全特性 + +| 特性 | 实现 | +|------|------| +| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16 字节随机盐,32 字节哈希 | +| 密码强度校验 | 至少 8 字符;大写/小写/数字/特殊字符中至少 3 类 | +| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期校验(默认 1 小时) | +| Token 吊销 | 双轨纯内存:jti 黑名单(5000 条阈值清理)+ 用户版本号(改密全端吊销) | +| 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack | +| 防用户枚举 | 登录对不存在用户执行 dummy PBKDF2 拉平时序;重置密码恒返回成功 | +| CAPTCHA | 统一走 captcha2 远端校验(Token 一次性消费);`/login` 强制 | +| TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | +| 下游容错 | pymsg 客户端 `retry_on_refused`:db/confs 未就绪时阻塞等待;日志客户端 fail-safe 静默丢弃 | + +## 接口 + +### `GET /captcha/image` + +代理 captcha2 签发图形验证码:响应体为 PNG,答案 Token 在 `X-Captcha-Token` 响应头(64 位 hex,一次性消费),`Cache-Control: no-store`。限流 10 次/分钟/IP(429)。 + +### `POST /login` + +```json +{ + "username": "user@example.com", + "password": "P@ssw0rd", + "captcha_token": "a1b2c3d4...", + "captcha_answer": "Ab3xy" +} +``` + +成功:`{"success": true, "data": {"token": "_"}}`,同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie。 +错误码:`400`(参数/CAPTCHA 错误)、`401`(账号/密码错)、`429`(限流)。 + +### `POST /register` / `POST /register/send_code` / `POST /reset/send_code` / `POST /reset_password` + +与 gateway v0.8.0 契约完全一致(详见 `docs/reports/auth_service_plan.md` §5 与黑盒 tests/blackbox/test_gateway.py)。 + +### `POST /logout` / `POST /refresh_token` / `POST /whoami` / `POST /admin/verify` + +- `/logout`:吊销当前 Token 的 jti 并清除 Cookie。 +- `/refresh_token`:宽限期(iat + keep_time × refresh_grace_multiplier)内换发新 Token,旧 jti 吊销,刷新次数超 `max_refresh` 后需重新登录。 +- `/whoami`:恒 200,未登录返回 `{"login": false}`。 +- `/admin/verify`:fail-closed,非管理员/无效 Token 一律 401(供 core:5200 管理员接口鉴权)。 + +### `GET/POST *`(前置转发) + +``` +Authorization: Bearer _ // 或 Cookie: q4_token=... +Target-Url: http://127.0.0.1:8006 // 可选;携带即视为受保护请求 +``` + +- 无 `Target-Url`:公开请求,剥身份头直转 gateway(由其 `Proxy.default` 路由)。 +- 有 `Target-Url`:验签通过后注入身份头转发;`Target-Url` 白名单由 gateway 侧 `Proxy.opened` 裁决(403)。 +- 上游故障:`502 {"success": false, "message": "Bad Gateway"}`。 + +### WebSocket 代理(`[WsProxy]` 配置的路径) + +握手阶段从 `Authorization: Bearer` / `Cookie: q4_token` 提取 Token,验签且 `is_admin=true` 才升级连接并桥接后端;否则拒绝。路由精确匹配,优先级高于 HTTP 通配 `*`。 + +## 配置 + +两层加载:**confs 统一配置服务**(启动时 pymsg `get` 拉取 `ca.ini`,`retry_on_refused=true`)→ **环境变量覆盖**(最高优先级)。 + +配置文件:[confs/configs/ca.ini](../confs/configs/ca.ini)。 + +- `Server.address`/`port`/`tls`:监听地址/端口/TLS(默认 `127.0.0.1:8443`,env `LISTEN_ADDR`/`LISTEN_PORT`)。 +- `Upstream.gateway`:唯一上游 gateway 地址(默认 `http://127.0.0.1:8445`,env `UPSTREAM_GATEWAY`)。 +- `Token.*`:`keep_time` / `refresh_grace_multiplier` / `max_refresh`。 +- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 +- `CORS.allowed_origins`:已读取但尚未用于预检响应(审计 P2-3)。 +- `WsProxy.routes`:`/path=ws://host:port/target`,分号分隔。 +- `Downstream.*`:db/captcha/smtp 地址(env `DB_*`/`CAPTCHA_*`/`SMTP_*`/`CONFS_*`)。 + +## 编译与运行 + +```bash +bash _set.sh # make clean && make +bash _run.sh # 从 confs 拉取配置启动(先 sleep 2 等待 confs/db) +``` + +命令行参数:`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `ca`)。 + +## 目录结构 + +``` +ca/ +├── src/ +│ └── main.cpp # 全部实现:认证 handler、Token 机制、限流、前置转发层、WS 代理 +├── inc/ +│ ├── DbC.hpp # db 服务客户端(pymsg,retry_on_refused=true;client 名 "ca") +│ ├── LogC.hpp # 统一日志客户端(异步批量,fail-safe;client 名 "ca-log") +│ ├── Conf.hpp # ConfIni:ini 解析 + env 覆盖 +│ ├── ExpBase.hpp # 异常基类(带 source_location) +│ └── LocalTime.hpp # 本地时间工具 +├── Makefile +├── _set.sh / _run.sh +└── README.md +``` + +## 版本 + +`1.0.0`:认证独立化首发——自 gateway v0.8.0 整体搬迁全部认证资产(逐字保留实现),新增前置转发层(固定上游 + strip-then-set 身份头契约 + 上游 502 语义)。原 gateway 同步瘦身为 v0.9.0 纯转发服务。 diff --git a/ca/_run.sh b/ca/_run.sh new file mode 100755 index 0000000..8c24dde --- /dev/null +++ b/ca/_run.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# ca 启动脚本 +# 配置统一由 confs 服务管理(confs/configs/ca.ini), +# 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 +cd "$(dirname "$0")" || exit + +# 等待依赖服务启动(confs/db) +sleep 2 + +exec ./build/ca.out "$@" diff --git a/ca/_set.sh b/ca/_set.sh new file mode 100755 index 0000000..95ae6ae --- /dev/null +++ b/ca/_set.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +make clear && make $@ diff --git a/ca/inc/Conf.hpp b/ca/inc/Conf.hpp new file mode 100644 index 0000000..a0b7d82 --- /dev/null +++ b/ca/inc/Conf.hpp @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +namespace property_tree = boost::property_tree; +using ptree = property_tree::ptree; +namespace qing { + + class ConfIni { + public: + /// 默认构造(空配置,后续通过赋值覆盖,仅供 confs 加载流程内部使用) + ConfIni() = default; + + /// 从文件路径解析 ini(保留用于开发/调试模式) + ConfIni(const std::string &filename) { + if (filename.empty()) return; // 空文件名不报错,后续会通过赋值覆盖 + try { + std::cout << "[conf] 从文件加载配置: " << filename << std::endl; + property_tree::ini_parser::read_ini(filename, pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + } + + /// 从 ini 文本字符串解析(用于从 confs 拉取后解析) + static ConfIni fromString(const std::string &ini_text) { + ConfIni c; + try { + std::istringstream iss(ini_text); + property_tree::ini_parser::read_ini(iss, c.pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 文本解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + return c; + } + + template + T get(const std::string &path, const T &_defaut) { + return pt.get(path, _defaut); + } + + /// 读取环境变量,不存在则返回默认值 + static std::string env(const char *name, const std::string &default_val = "") { + const char *v = std::getenv(name); + return v ? std::string(v) : default_val; + } + + static int env_int(const char *name, int default_val) { + const char *v = std::getenv(name); + if (!v || !*v) return default_val; + try { return std::stoi(v); } + catch (...) { return default_val; } + } + + class IniConfigureFileParseException: public std::exception { + public: + explicit IniConfigureFileParseException(const std::string &msg) + : message(msg) {} + const char* what() const noexcept override { + return message.c_str(); + } + private: + std::string message; + }; + + private: + ptree pt; + }; +} diff --git a/ca/inc/DbC.hpp b/ca/inc/DbC.hpp new file mode 100644 index 0000000..7d272e0 --- /dev/null +++ b/ca/inc/DbC.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +namespace qing { + namespace json = boost::json; + class DbC { + public: + /* 本机 db 轻量查询的默认超时(秒);慢操作由调用方显式放宽。 + * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ + static constexpr double DEFAULT_TIMEOUT = 5.0; + + /// 初始化 db 服务地址(启动时从配置设置,默认 127.0.0.1:8005) + static void init(const std::string& host, int port) { + _host = host; + _port = port; + } + + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 + // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 + static json::value call_http(json::value& params) { + + auto client = pmc::net::HttpClient(); + client.set_header("Content-Type: application/json"); + std::string payload = json::serialize(params); + std::string res = ""; + std::string url = "http://" + _host + ":" + std::to_string(_port) + "/execute"; + client.post_stream_sync(url, payload, [&res](const auto& ret) { + res += ret; + }); + return json::parse(res); + } + + // 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。 + // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 + // {"success":..., "data":[...]}。 + // 响应格式与 call_http 兼容,调用方可直接替换。 + // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 + static json::value call_msg(const json::value& params, + double timeout = DEFAULT_TIMEOUT) { + json::value jv_route = params; + jv_route.as_object()["_route"] = "execute"; + pmc::net::MsgClient client(_host, _port, "ca"); + std::string resp = client.call( + json::serialize(jv_route), timeout, + /*retry_on_refused=*/true); + return json::parse(resp); + } + + static const std::string& host() { return _host; } + static int port() { return _port; } + + private: + static inline std::string _host = "127.0.0.1"; + static inline int _port = 8005; + }; +} diff --git a/ca/inc/ExpBase.hpp b/ca/inc/ExpBase.hpp new file mode 100644 index 0000000..c2509c6 --- /dev/null +++ b/ca/inc/ExpBase.hpp @@ -0,0 +1,411 @@ +#ifndef EXCEPTION_BASE_H +#define EXCEPTION_BASE_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief 通用异常基类 + * + * 特性: + * 1. 完整的错误信息管理(错误码、消息、位置信息) + * 2. 支持异常链(嵌套异常) + * 3. 多态支持(clone、自定义消息) + * 4. 线程安全的 what() 缓存 + * 5. 支持 C++20 source_location + */ +class ExceptionBase : public std::exception { +public: + // ============ 构造函数 ============ + + explicit ExceptionBase( + int code = 0, + const std::string& message = "", + const std::source_location& location = std::source_location::current() + ) noexcept + : m_code(code) + , m_message(message) + , m_file(location.file_name()) + , m_line(location.line()) + , m_function(location.function_name()) + , m_what_cache() + { + } + + // 支持从字符串字面量构造 + explicit ExceptionBase( + const std::string& message, + const std::source_location& location = std::source_location::current() + ) noexcept + : m_code(0) + , m_message(message) + , m_file(location.file_name()) + , m_line(location.line()) + , m_function(location.function_name()) + , m_what_cache() + { + } + + // ============ 拷贝和移动 ============ + + ExceptionBase(const ExceptionBase& other) noexcept + : std::exception(other) + , m_code(other.m_code) + , m_message(other.m_message) + , m_file(other.m_file) + , m_line(other.m_line) + , m_function(other.m_function) + , m_nested(other.m_nested ? other.m_nested->clone() : nullptr) + , m_what_cache() + { + } + + ExceptionBase(ExceptionBase&& other) noexcept + : std::exception(std::move(other)) + , m_code(other.m_code) + , m_message(std::move(other.m_message)) + , m_file(std::move(other.m_file)) + , m_line(other.m_line) + , m_function(std::move(other.m_function)) + , m_nested(std::move(other.m_nested)) + , m_what_cache(std::move(other.m_what_cache)) + { + other.m_code = 0; + other.m_line = 0; + } + + virtual ~ExceptionBase() noexcept = default; + + // ============ 赋值运算符 ============ + + ExceptionBase& operator=(const ExceptionBase& other) noexcept { + if (this != &other) { + m_code = other.m_code; + m_message = other.m_message; + m_file = other.m_file; + m_line = other.m_line; + m_function = other.m_function; + m_nested = other.m_nested ? other.m_nested->clone() : nullptr; + m_what_cache.clear(); + } + return *this; + } + + ExceptionBase& operator=(ExceptionBase&& other) noexcept { + if (this != &other) { + m_code = other.m_code; + m_message = std::move(other.m_message); + m_file = std::move(other.m_file); + m_line = other.m_line; + m_function = std::move(other.m_function); + m_nested = std::move(other.m_nested); + m_what_cache = std::move(other.m_what_cache); + other.m_code = 0; + other.m_line = 0; + } + return *this; + } + + // ============ 访问器 ============ + + int code() const noexcept { return m_code; } + const std::string& message() const noexcept { return m_message; } + const std::string& file() const noexcept { return m_file; } + int line() const noexcept { return m_line; } + const std::string& function() const noexcept { return m_function; } + const ExceptionBase* nested() const noexcept { return m_nested.get(); } + + // ============ 异常链操作 ============ + + void set_nested(std::unique_ptr nested) noexcept { + m_nested = std::move(nested); + m_what_cache.clear(); + } + + template + void set_nested(const ExType& nested_ex) noexcept { + static_assert(std::is_base_of::value, + "ExType must inherit from ExceptionBase"); + m_nested = std::make_unique(nested_ex); + m_what_cache.clear(); + } + + // ============ std::exception 接口 ============ + + const char* what() const noexcept override { + if (m_what_cache.empty()) { + build_what_message(); + } + return m_what_cache.c_str(); + } + + // ============ 多态支持 ============ + + virtual std::unique_ptr clone() const { + return std::make_unique(*this); + } + + // ============ 重新抛出 ============ + + [[noreturn]] void rethrow() const { + throw *this; + } + +protected: + // ============ 可重写的消息构建方法 ============ + + virtual std::string build_message() const { + std::string result; + + // 错误码 + if (m_code != 0) { + result += "[Error " + std::to_string(m_code) + "] "; + } + + // 错误消息 + result += m_message; + + /* ===== 以下「位置信息」与「嵌套异常」暂时以注释方式保留 ===== + * 原因:黑盒检测发现 what() 的返回值会透传到 HTTP 响应体, + * 导致泄露源码文件路径、行号、C++ 模板类型签名(函数名), + * 便于攻击者定位代码、确认技术栈、构造针对性 payload(P0-3)。 + * 排障:完整信息仍由各 handler 通过 std::cerr 输出到服务端日志。 + * 恢复:如需调试可临时取消下方注释;生产环境必须保持注释状态, + * 并确保 what() 不直接进入客户端响应。 + * ==================================================================== + // 位置信息 + if (!m_file.empty()) { + result += " (at " + m_file; + if (m_line > 0) { + result += ":" + std::to_string(m_line); + } + if (!m_function.empty()) { + result += " in " + m_function; + } + result += ")"; + } + + // 嵌套异常 + if (m_nested) { + result += "\n Caused by: " + std::string(m_nested->what()); + } + * ==================================================================== */ + + return result; + } + +private: + // ============ 内部方法 ============ + + void build_what_message() const noexcept { + try { + m_what_cache = build_message(); + } catch (...) { + // 降级处理 + m_what_cache = "Exception: " + m_message; + } + } + +private: + // ============ 成员变量 ============ + + int m_code; + std::string m_message; + std::string m_file; + int m_line; + std::string m_function; + std::unique_ptr m_nested; + mutable std::string m_what_cache; +}; + +// ============================================================================ +// 宏定义 - 方案二完整版 +// ============================================================================ + +/** + * @brief 抛出指定类型的异常(自动添加 source_location) + * + * 适用场景:子类构造函数与 ExceptionBase 相同 + * + * 使用示例: + * THROW_EX(NetworkException, 404, "Server not found") + * THROW_EX(DatabaseException, 100, "Connection timeout") + */ +#define THROW_EX(ExType, code, msg) \ + throw ExType(code, msg, std::source_location::current()) + +/** + * @brief 抛出异常(仅消息,不传错误码) + * + * 适用场景:子类支持只传消息的构造函数 + * + * 使用示例: + * THROW_EX_MSG(ValidationException, "Invalid input data") + */ +#define THROW_EX_MSG(ExType, msg) \ + throw ExType(msg, std::source_location::current()) + +/** + * @brief 抛出异常(支持任意构造函数参数) + * + * 适用场景:子类有额外的构造函数参数 + * + * 使用示例: + * // 子类定义 + * class FileException : public ExceptionBase { + * public: + * FileException(int code, const std::string& msg, + * const std::string& filename, + * const std::source_location& loc = std::source_location::current()) + * : ExceptionBase(code, msg, loc), m_filename(filename) {} + * }; + * + * // 使用 + * THROW_EX_CUSTOM(FileException, 404, "File not found", "/etc/config.txt") + */ +#define THROW_EX_CUSTOM(ExType, ...) \ + throw ExType(__VA_ARGS__, std::source_location::current()) + +/** + * @brief 抛出异常并携带嵌套异常 + * + * 适用场景:需要保留原始异常信息 + * + * 使用示例: + * try { + * connect_to_server(); + * } catch (const NetworkException& e) { + * THROW_EX_NESTED(ServiceException, 500, "Service failed", e) + * } + */ +#define THROW_EX_NESTED(ExType, code, msg, nested_ex) \ + do { \ + auto ex = ExType(code, msg, std::source_location::current()); \ + ex.set_nested(nested_ex); \ + throw ex; \ + } while(0) + +/** + * @brief 抛出异常并携带嵌套异常(支持自定义参数) + * + * 使用示例: + * THROW_EX_NESTED_CUSTOM(FileException, 500, "Read failed", + * original_ex, "/data/file.txt") + */ +#define THROW_EX_NESTED_CUSTOM(ExType, code, msg, nested_ex, ...) \ + do { \ + auto ex = ExType(code, msg, __VA_ARGS__, std::source_location::current()); \ + ex.set_nested(nested_ex); \ + throw ex; \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常 + * + * 使用示例: + * THROW_IF(ptr == nullptr, NullPointerException, 100, "Pointer is null") + * THROW_IF(data.empty(), ValidationException, 200, "Data cannot be empty") + */ +#define THROW_IF(cond, ExType, code, msg) \ + do { \ + if (cond) { \ + THROW_EX(ExType, code, msg); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(仅消息) + */ +#define THROW_IF_MSG(cond, ExType, msg) \ + do { \ + if (cond) { \ + THROW_EX_MSG(ExType, msg); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(自定义参数) + */ +#define THROW_IF_CUSTOM(cond, ExType, ...) \ + do { \ + if (cond) { \ + THROW_EX_CUSTOM(ExType, __VA_ARGS__); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(带嵌套异常) + */ +#define THROW_IF_NESTED(cond, ExType, code, msg, nested_ex) \ + do { \ + if (cond) { \ + THROW_EX_NESTED(ExType, code, msg, nested_ex); \ + } \ + } while(0) + +/** + * @brief 检查条件,失败则抛出异常(带嵌套异常和自定义参数) + */ +#define THROW_IF_NESTED_CUSTOM(cond, ExType, code, msg, nested_ex, ...) \ + do { \ + if (cond) { \ + THROW_EX_NESTED_CUSTOM(ExType, code, msg, nested_ex, __VA_ARGS__); \ + } \ + } while(0) + +// ============================================================================ +// 辅助宏 - 用于格式化错误消息 +// ============================================================================ + +/** + * @brief 格式化错误消息(使用流式语法) + * + * 使用示例: + * THROW_EX_FMT(NetworkException, 404, "Failed to connect to " << host << ":" << port) + */ +#define THROW_EX_FMT(ExType, code, msg_expr) \ + do { \ + std::ostringstream _oss; \ + _oss << msg_expr; \ + THROW_EX(ExType, code, _oss.str()); \ + } while(0) + +/** + * @brief 格式化错误消息(使用 printf 风格) + * + * 使用示例: + * THROW_EX_PRINTF(NetworkException, 404, "Failed to connect to %s:%d", host.c_str(), port) + */ +#define THROW_EX_PRINTF(ExType, code, fmt, ...) \ + do { \ + char _buf[1024]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + THROW_EX(ExType, code, std::string(_buf)); \ + } while(0) + +/** + * @brief 条件检查 + 格式化消息 + */ +#define THROW_IF_FMT(cond, ExType, code, msg_expr) \ + do { \ + if (cond) { \ + THROW_EX_FMT(ExType, code, msg_expr); \ + } \ + } while(0) + +/** + * @brief 条件检查 + printf 风格格式化 + */ +#define THROW_IF_PRINTF(cond, ExType, code, fmt, ...) \ + do { \ + if (cond) { \ + THROW_EX_PRINTF(ExType, code, fmt, __VA_ARGS__); \ + } \ + } while(0) + +#endif // EXCEPTION_BASE_H diff --git a/ca/inc/LocalTime.hpp b/ca/inc/LocalTime.hpp new file mode 100644 index 0000000..2883845 --- /dev/null +++ b/ca/inc/LocalTime.hpp @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace qing { + +class LocalTime { +public: + // 获取当前本地时间字符串 + static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm_buf{}; + localtime_r(&t, &tm_buf); + char buf[128]; + std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf); + return std::string(buf); + } + + // 解析时间字符串为 system_clock::time_point(本地时间 → UTC) + static std::optional parse( + const std::string& str, + const std::string& format = "%Y-%m-%d %H:%M:%S" + ) { + std::tm tm_buf{}; + std::istringstream iss{str}; + iss >> std::get_time(&tm_buf, format.c_str()); + if (iss.fail()) { + return std::nullopt; + } + // mktime 将本地时间转换为 time_t(UTC) + std::time_t t = std::mktime(&tm_buf); + if (t == -1) { + return std::nullopt; + } + return std::chrono::system_clock::from_time_t(t); + } + + // ----- 过期判断 ----- + static bool isExpired(const std::chrono::system_clock::time_point& tp) { + return std::chrono::system_clock::now() > tp; + } + + template + static bool isExpired(const std::chrono::system_clock::time_point& tp, + const std::chrono::duration& gracePeriod) { + return std::chrono::system_clock::now() > tp + gracePeriod; + } + + static bool isExpired(const std::string& timeStr, + const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto parsed = parse(timeStr, format); + if (!parsed) return true; + return isExpired(*parsed); + } + + template + static bool isExpired(const std::string& timeStr, + const std::chrono::duration& gracePeriod, + const std::string& format = "%Y-%m-%d %H:%M:%S") { + auto parsed = parse(timeStr, format); + if (!parsed) return true; + return isExpired(*parsed, gracePeriod); + } +}; + + +} diff --git a/ca/inc/LogC.hpp b/ca/inc/LogC.hpp new file mode 100644 index 0000000..e58736e --- /dev/null +++ b/ca/inc/LogC.hpp @@ -0,0 +1,206 @@ +#pragma once + +/** LogC — ca 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py) + * + * 设计要点(与 Python 版对齐): + * - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + * - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送,不拖慢业务 + * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 + * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + * - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长 + * + * 用法: + * #include "LogC.hpp" + * + * qing::LogC::instance().info("ca", "启动完成"); + * qing::LogC::instance().error("ca", "数据库连接失败", + * std::nullopt, R"({"errno":2002})"); + * + * // 进程退出前: + * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace qing { + +namespace json = boost::json; + +class LogC { +public: + struct Entry { + std::string service; + std::string level; + std::string message; + std::optional trace_id; + std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) + }; + + static LogC& instance() { + static LogC inst; + return inst; + } + + // ---- 公开接口 -------------------------------------------------------- + + // 推送一条日志(非阻塞,立即返回) + void push(const std::string& service, const std::string& level, + const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + _queue.push(Entry{service, level, message, + std::move(trace_id), std::move(extra)}); + } + + void debug(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); + } + void info(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "INFO", message, std::move(trace_id), std::move(extra)); + } + void warning(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "WARNING", message, std::move(trace_id), std::move(extra)); + } + void error(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "ERROR", message, std::move(trace_id), std::move(extra)); + } + void critical(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); + } + + // 手动 flush:通知后台线程立即发送当前积攒的批次,并等待其完成 + void flush() { + _queue.flush(); + } + + // 停止后台线程并 flush 剩余日志(进程退出前调用,幂等) + void shutdown() { + _queue.shutdown(); + } + + // ---- 配置(构造后、首次推送前可改)------------------------------------ + void set_endpoint(const std::string& addr, int port) { + std::lock_guard lock(_endpoint_mtx); + _db_addr = addr; + _db_port = port; + } + +private: + LogC() + : _queue( + [this](std::vector&& batch) { this->_send_batch(std::move(batch)); }, + {_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5}, + [](const std::exception& e, std::vector&&) { + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + }) + { + _queue.start(); + } + + ~LogC() { shutdown(); } + + LogC(const LogC&) = delete; + LogC& operator=(const LogC&) = delete; + + // 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表 + void _send_batch(const std::vector& batch) { + std::string addr; + int port; + { + std::lock_guard lock(_endpoint_mtx); + addr = _db_addr; + port = _db_port; + } + + const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版) + try { + pmc::net::MsgClient client(addr, port, "ca-log"); + for (const auto& e : batch) { + json::object values; + values["ts"] = now; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + values["trace_id"] = e.trace_id ? json::value(*e.trace_id) + : json::value(nullptr); + values["extra"] = e.extra ? json::value(*e.extra) + : json::value(nullptr); + + json::object payload; + payload["_route"] = "execute"; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = std::move(values); + + const std::string resp = client.call( + json::serialize(payload), _TIMEOUT, + /*retry_on_refused=*/false); // 日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞重试 + auto obj = json::parse(resp).as_object(); + if (!obj.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = obj.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[LogC] INSERT 失败: " << err << std::endl; + } + } + } catch (const std::exception& e) { + // fail-safe:db 不可用时静默丢弃,绝不影响业务 + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + } + } + + // UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat()) + // 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理 + static std::string _utc_now_iso() { + const auto now = std::chrono::system_clock::now(); + const auto t = std::chrono::system_clock::to_time_t(now); + const auto us = std::chrono::duration_cast( + now.time_since_epoch()).count() % 1000000; + std::tm tm{}; + ::gmtime_r(&t, &tm); + char buf[40]; + std::snprintf(buf, sizeof buf, + "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); + return buf; + } + + // ---- 配置项 ----------------------------------------------------------- + std::string _db_addr = "127.0.0.1"; + int _db_port = 8005; + std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) + + static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush + static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush + static constexpr size_t _MAX_QUEUE_SIZE = 10000; // 队列上限,满时丢最旧 + static constexpr double _TIMEOUT = 5.0; // socket 超时(秒) + + // ---- 核心队列 --------------------------------------------------------- + pmc::net::AsyncQueue _queue; +}; + +} // namespace qing diff --git a/ca/src/main.cpp b/ca/src/main.cpp new file mode 100644 index 0000000..78b7a44 --- /dev/null +++ b/ca/src/main.cpp @@ -0,0 +1,2463 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ExpBase.hpp" +#include "LocalTime.hpp" +#include "Conf.hpp" +#include "DbC.hpp" +#include "LogC.hpp" + +using namespace pmc::net; +using namespace qing; +namespace json = boost::json; +namespace po = boost::program_options; + +constexpr const char *_VER = "1.0.0"; + +////////////////////////////////// +#include +#include +#include + +/* by suiyu + * + * 有一丢丢消耗资源(qing) */ +std::vector split(const std::string& s, char delim) { + std::vector tokens; + std::istringstream stream(s); + std::string token; + while (std::getline(stream, token, delim)) { + // 跳过空串:避免 "a;b;" 末尾分号产生 "" 元素 + // 防御白名单匹配场景下空串被误判为命中 + if (!token.empty()) { + tokens.push_back(token); + } + } + return tokens; +} + +////////////////////////////////// +// PBKDF2-HMAC-SHA256 password hashing +////////////////////////////////// +class PBKDF2Hasher { +public: + static const int ITERATIONS = 100000; + static const int SALT_LEN = 16; + static const int HASH_LEN = 32; + + static std::string hash_password(const std::string& password) { + unsigned char salt[SALT_LEN]; + RAND_bytes(salt, SALT_LEN); + + unsigned char hash[HASH_LEN]; + PKCS5_PBKDF2_HMAC( + password.c_str(), password.size(), + salt, SALT_LEN, + ITERATIONS, + EVP_sha256(), + HASH_LEN, hash + ); + + // Format: pbkdf2:100000:: + std::string salt_hex = bytes_to_hex(salt, SALT_LEN); + std::string hash_hex = bytes_to_hex(hash, HASH_LEN); + return "pbkdf2:" + std::to_string(ITERATIONS) + ":" + salt_hex + ":" + hash_hex; + } + + static bool verify_password(const std::string& password, const std::string& stored) { + // Parse stored hash + if (stored.substr(0, 7) != "pbkdf2:") { + return false; + } + + auto parts = split(stored, ':'); + if (parts.size() != 4) return false; + + int iterations = std::stoi(parts[1]); + auto salt = hex_to_bytes(parts[2]); + auto expected_hash = hex_to_bytes(parts[3]); + + unsigned char hash[HASH_LEN]; + PKCS5_PBKDF2_HMAC( + password.c_str(), password.size(), + salt.data(), salt.size(), + iterations, + EVP_sha256(), + HASH_LEN, hash + ); + + // Constant-time comparison + if (expected_hash.size() != (size_t)HASH_LEN) return false; + unsigned char diff = 0; + for (int i = 0; i < HASH_LEN; i++) { + diff |= hash[i] ^ expected_hash[i]; + } + return diff == 0; + } + +private: + static std::string bytes_to_hex(const unsigned char* data, size_t len) { + static const char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(len * 2); + for (size_t i = 0; i < len; i++) { + result.push_back(hex[data[i] >> 4]); + result.push_back(hex[data[i] & 0x0F]); + } + return result; + } + + static std::vector hex_to_bytes(const std::string& hex_str) { + std::vector bytes; + bytes.reserve(hex_str.size() / 2); + for (size_t i = 0; i < hex_str.size(); i += 2) { + auto hi = hex_char_to_nibble(hex_str[i]); + auto lo = hex_char_to_nibble(hex_str[i + 1]); + bytes.push_back((hi << 4) | lo); + } + return bytes; + } + + static unsigned char hex_char_to_nibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return 0; + } +}; + +////////////////////////////////// +// Password strength validator +////////////////////////////////// +class PasswordValidator { +public: + struct Result { + bool valid; + std::string message; + }; + + static Result validate(const std::string& password) { + if (password.size() < 8) { + return {false, "Password must be at least 8 characters"}; + } + if (password.size() > 128) { + return {false, "Password too long (max 128)"}; + } + + bool has_upper = false, has_lower = false, has_digit = false, has_special = false; + for (char c : password) { + // std::isupper 等函数参数为 int,char 为有符号且非 ASCII 时是 UB + // 必须 static_cast 后再传入(P1-2) + unsigned char uc = static_cast(c); + if (std::isupper(uc)) has_upper = true; + else if (std::islower(uc)) has_lower = true; + else if (std::isdigit(uc)) has_digit = true; + else has_special = true; + } + + int categories = has_upper + has_lower + has_digit + has_special; + if (categories < 3) { + return {false, "Password must contain at least 3 of: uppercase, lowercase, digit, special character"}; + } + + return {true, "OK"}; + } +}; + +////////////////////////////////// +// IP Rate Limiter (in-memory) +////////////////////////////////// +#include +#include +#include + +class RateLimiter { +public: + struct AttemptInfo { + int count; + std::chrono::steady_clock::time_point first_attempt; + }; + + bool check_and_record(const std::string& ip, int max_attempts = 5, int window_seconds = 300) { + std::lock_guard lock(mtx); + auto now = std::chrono::steady_clock::now(); + + auto& info = attempts[ip]; + + // Reset if window expired + auto elapsed = std::chrono::duration_cast(now - info.first_attempt).count(); + if (elapsed > window_seconds) { + info.count = 0; + info.first_attempt = now; + } + + info.count++; + + // Cleanup old entries periodically + if (attempts.size() > 10000) { + for (auto it = attempts.begin(); it != attempts.end(); ) { + auto e = std::chrono::duration_cast(now - it->second.first_attempt).count(); + if (e > window_seconds) it = attempts.erase(it); + else ++it; + } + } + + return info.count <= max_attempts; + } + +private: + std::unordered_map attempts; + std::mutex mtx; +}; + +////////////////////////////////// +/* -------- 数据库异常\数据库操作失败 -------- */ +class DatabaseException: public ExceptionBase { +public: + using ExceptionBase::ExceptionBase; + + std::string build_message() const override { + return "[Database]" + ExceptionBase::build_message(); + } +}; + +/* http异常、非200 */ +class HttpException: public ExceptionBase { +public: + HttpException(int code, const std::string& msg, + int http_status, const std::string& url, + const std::source_location& loc = std::source_location::current()) + : ExceptionBase(code, msg, loc) + , m_http_status(http_status) + , m_url(url) {} + std::string build_message() const override { + return "[Http]" + ExceptionBase::build_message(); + } + int status() const { + return m_http_status; + } + const std::string& url() const { + return m_url; + } +private: + int m_http_status; + std::string m_url; +}; + + +//void db_init(); /* 初始化数据库 */ +void registerRoutes(HttpServer &server); + +/* http视图包装器,主要是将异常回显到浏览器 */ +http::response wrapperHttp( + const http::request& req, + const std::unordered_map& params, + HttpRequestHandler _main); + + +/* 前置转发视图(转发到 gateway),额外接受一个method字符串 */ +http::response handleForward( + const http::request& req, + const std::unordered_map& params, + const char *method +); + +/* */ +http::response handleLogin( + const http::request& req, + const std::unordered_map& params); + +http::response handleRegister( + const http::request& req, + const std::unordered_map& params); + +/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params); + +/* 发送重置密码邮箱验证码:pymsg 调 smtp 服务 send_email 路由(定制重置文案) */ +http::response handleResetSendCode( + const http::request& req, + const std::unordered_map& params); + +/* 重置密码:与注册流程一致,但最后一步由创建用户改为更新密码 */ +http::response handleResetPassword( + const http::request& req, + const std::unordered_map& params); + +http::response handleLogout( + const http::request& req, + const std::unordered_map& params); + +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params); + +/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params); + +/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */ +http::response handleAdminVerify( + const http::request& req, + const std::unordered_map& params); + +/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ +http::response handleCaptchaImage( + const http::request& req, + const std::unordered_map& params); + +/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */ +std::string get_cookie_value(const std::string& cookie_header, const std::string& name); + + +/* headers can't processed */ +bool is_special_header(const std::string &header); + +/* 请求路径安全性校验(防路径遍历/协议注入/空字节) */ +bool is_safe_target(const std::string& target); + +/* ===== Token 吊销机制 ===== */ +// Token 验签结果结构体 +struct TokenInfo { + bool valid = false; + std::string username; + std::string jti; // Token 唯一标识(用于单 Token 吊销) + int ver = 1; // 用户 Token 版本号(用于全设备吊销) + bool is_admin = false; + int keep_time = 3600; + int refresh_count = 0; + int max_refresh = 10; + std::string iat; + std::string created_at; + int http_status = 401; // 验签失败时的推荐 HTTP 状态码 +}; + +// 生成 128-bit 随机 JTI(hex 字符串,32 字符) +std::string generate_jti(); + +// 获取用户当前 Token 版本号(不存在则初始化为 1) +int get_user_ver(const std::string& username); + +// 吊销单个 Token(将 jti 加入黑名单直到其过期) +void revoke_jti(const std::string& jti, int keep_time); + +// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效) +void revoke_all_user_tokens(const std::string& username); + +// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token) +// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串 +// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入) +std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token); + +// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查 +// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀) +TokenInfo verify_token(const std::string& raw_token); + +/* configure items */ +static std::string _ADDR; +static int _PORT; +static bool _TLS = false; +/* 上游 gateway 地址(前置拓扑:ca 认证后转发至此),由 [Upstream] gateway 配置 */ +static std::string _UPSTREAM_GATEWAY = "http://127.0.0.1:8445"; +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 + +/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +static std::string _DB_HOST = "127.0.0.1"; +static int _DB_PORT = 8005; +static std::string _CAPTCHA_HOST = "127.0.0.1"; +static int _CAPTCHA_PORT = 8009; +static std::string _SMTP_HOST = "127.0.0.1"; +static int _SMTP_PORT = 8008; +/* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ +static std::string _CONFS_HOST = "127.0.0.1"; +static int _CONFS_PORT = 8010; + +/* WebSocket 代理路由:path -> ws://backend-host:port/target + * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ +static std::unordered_map _WS_PROXY_ROUTES; + +/* instance items */ +static std::unique_ptr _SERVE; +static std::unique_ptr _KEYGEN; +//static std::unique_ptr _SQLDB; +static RateLimiter _LOGIN_RATE; +static RateLimiter _REGISTER_RATE; +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 +static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 +static RateLimiter _RESET_PASSWORD_RATE; // 重置密码 IP 级限流 +static RateLimiter _RESET_CODE_RATE; // 发送重置密码验证码 IP 级限流 +static RateLimiter _CAPTCHA_IMAGE_RATE; // 获取验证码图片 IP 级限流(防全局 DoS,P1-1) +// 邮箱级双窗口限流:同一邮箱在 ca 层的发送配额。 +// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。 +// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。 +static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱 +static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱 + +/* pymsg 下游调用超时(秒): + * captcha / smtp verify_email 均为本机内存操作,5s 足够; + * smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。 + * db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */ +static constexpr double _TIMEOUT_CAPTCHA = 5.0; +static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0; +static constexpr double _TIMEOUT_SMTP_SEND = 20.0; + +/* CAPTCHA store: token -> answer (in-memory, with expiry) */ +#include +#include + +/* Token 吊销机制(P1:Token 吊销缺失修复) + * + * 双轨纯内存方案: + * 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理 + * 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取 + * + * 设计优势: + * - 零 DB 改动:黑名单和版本号全在 ca 进程内存中 + * - 重启自动清空:ca 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败 + * - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响 + * - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理 + */ +struct JtiDenylistEntry { + std::chrono::steady_clock::time_point expire_at; +}; +static std::unordered_map _JTI_DENYLIST; +static std::unordered_map _USER_VER_MAP; +static std::mutex _TOKEN_REVOKE_MTX; +static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理 + +/* 集中式日志客户端:推送 ca 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */ +static inline LogC& logs() { return LogC::instance(); } + +/* 调用 captcha 服务(pymsg/TCP :8009)校验图形验证码。 + * Token 一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 + * 唯一验证码渠道:内置 _CAPTCHA_STORE 渠道已废弃(审计 A6/A7)—— + * 其接口明文返回答案、无限流、存储无界,且与前端 /captcha/image 渠道不互通。 */ +bool verify_captcha_remote(const std::string& token, const std::string& answer) { + try { + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "ca"); + json::value jv_req = { + {"_route", "verify"}, + {"token", token}, + {"answer", answer} + }; + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA); + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) return false; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + +/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 + * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ +bool verify_email_code_remote(const std::string& email, const std::string& code) { + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "verify_email"}, + {"email", email}, + {"code", code} + }; + std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY); + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) return false; + return obj.at("data").as_object().at("valid").as_bool(); + } catch (const std::exception&) { + return false; + } +} + +/* ===== Token 吊销机制实现 ===== */ + +std::string generate_jti() { + unsigned char jti_bytes[16]; + RAND_bytes(jti_bytes, 16); + std::string jti; + static const char hex[] = "0123456789abcdef"; + jti.reserve(32); + for (int i = 0; i < 16; i++) { + jti += hex[jti_bytes[i] >> 4]; + jti += hex[jti_bytes[i] & 0x0F]; + } + return jti; +} + +int get_user_ver(const std::string& username) { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it = _USER_VER_MAP.find(username); + if (it == _USER_VER_MAP.end()) { + _USER_VER_MAP[username] = 1; + return 1; + } + return it->second; +} + +void revoke_jti(const std::string& jti, int keep_time) { + if (jti.empty()) return; + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time); + _JTI_DENYLIST[jti] = {expire}; + + // 超过阈值时批量清理过期条目 + if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) { + auto now = std::chrono::steady_clock::now(); + for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) { + if (now > it->second.expire_at) + it = _JTI_DENYLIST.erase(it); + else + ++it; + } + } +} + +void revoke_all_user_tokens(const std::string& username) { + if (username.empty()) return; + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it = _USER_VER_MAP.find(username); + if (it == _USER_VER_MAP.end()) { + _USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2 + } else { + it->second++; + } + logs().info("ca", "用户所有Token已吊销: " + username, std::nullopt, + json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}})); +} + +std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token) { + out_cookie_token.clear(); + std::string raw; + + // 1. 优先从 Authorization: Bearer 头获取 + auto it_auth = req.base().find("Authorization"); + if (it_auth != req.base().end()) { + raw = std::string(it_auth->value()); + } + + // 2. 回退从 Cookie q4_token 获取 + if (raw.empty()) { + auto cit = req.base().find("Cookie"); + if (cit != req.base().end()) { + std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token"); + if (!cookie_token.empty()) { + raw = "Bearer " + cookie_token; + out_cookie_token = cookie_token; + } + } + } + + // 去掉 "Bearer " 前缀 + if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") { + return raw.substr(7); + } + return raw; // 可能为空或不含前缀,由调用方处理 +} + +// 清理 denylist 中指定 jti(如果已过期则删除) +static void cleanup_jti_if_expired(const std::string& jti) { + auto now = std::chrono::steady_clock::now(); + auto it = _JTI_DENYLIST.find(jti); + if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) { + _JTI_DENYLIST.erase(it); + } +} + +TokenInfo verify_token(const std::string& raw_token) { + TokenInfo info; + info.valid = false; + + if (raw_token.empty()) { + info.http_status = 401; + return info; + } + + // 解析 base64data_signature + size_t idx = raw_token.find("_"); + if (idx == std::string::npos) { + info.http_status = 400; + return info; + } + + std::string data = raw_token.substr(0, idx); + std::string sign = raw_token.substr(idx + 1); + std::string auth; + try { + auth = Base64::base64_decode_to_string(data); + } catch (const std::exception&) { + info.http_status = 400; + return info; + } + + // RSA 验签 + auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); + if (!publ.Verify(auth, sign)) { + info.http_status = 401; + return info; + } + + // 解析 payload + json::value jv_t; + try { + jv_t = json::parse(auth); + } catch (const std::exception&) { + info.http_status = 400; + return info; + } + if (!jv_t.is_object()) { + info.http_status = 400; + return info; + } + + json::object& obj = jv_t.as_object(); + + // 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况) + try { + info.username = json::value_to(obj.at("user")); + } catch (...) { + info.http_status = 400; + return info; + } + + // 过期检查 + auto it_created = obj.find("created_at"); + if (it_created != obj.end() && it_created->value().is_string()) { + info.created_at = json::value_to(it_created->value()); + } + auto it_keep = obj.find("keep_time"); + if (it_keep != obj.end() && it_keep->value().is_int64()) { + info.keep_time = static_cast(it_keep->value().as_int64()); + } + if (!info.created_at.empty()) { + auto created_tp = LocalTime::parse(info.created_at); + if (created_tp) { + auto now = std::chrono::system_clock::now(); + auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time); + if (now > expire_tp) { + info.http_status = 401; + return info; + } + } + } + + // is_admin + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) { + info.is_admin = it_admin->value().as_bool(); + } + + // refresh_count / max_refresh / iat + auto it_rc = obj.find("refresh_count"); + if (it_rc != obj.end() && it_rc->value().is_int64()) + info.refresh_count = static_cast(it_rc->value().as_int64()); + auto it_mr = obj.find("max_refresh"); + if (it_mr != obj.end() && it_mr->value().is_int64()) + info.max_refresh = static_cast(it_mr->value().as_int64()); + auto it_iat = obj.find("iat"); + if (it_iat != obj.end() && it_iat->value().is_string()) + info.iat = json::value_to(it_iat->value()); + + // jti 黑名单检查(单 Token 吊销) + auto it_jti = obj.find("jti"); + if (it_jti != obj.end() && it_jti->value().is_string()) { + info.jti = json::value_to(it_jti->value()); + std::lock_guard lock(_TOKEN_REVOKE_MTX); + cleanup_jti_if_expired(info.jti); // 惰性清理 + if (_JTI_DENYLIST.count(info.jti)) { + info.http_status = 401; + return info; + } + } + + // 版本号检查(全设备吊销:改密后版本号递增) + // 兼容旧 Token 无 ver 字段:视为 ver=1 + auto it_ver = obj.find("ver"); + int token_ver = 1; + if (it_ver != obj.end() && it_ver->value().is_int64()) { + token_ver = static_cast(it_ver->value().as_int64()); + } + info.ver = token_ver; + { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it_uv = _USER_VER_MAP.find(info.username); + int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; + if (token_ver < current_ver) { + info.http_status = 401; + return info; + } + } + + // 全部通过 + info.valid = true; + info.http_status = 200; + return info; +} + +/* -------- 信号处理 -------- + * + * 安全设计原则: + * 1. 信号处理函数中只做 async-signal-safe 操作:设置 volatile sig_atomic_t 标志 + * 2. 绝不调用 std::cout、malloc、锁、ioc_->stop() 等非安全函数(可能导致死锁) + * 3. 主线程在安全点检测标志后再执行完整的关闭流程 + * 4. 忽略 SIGPIPE(写已关闭 socket 时内核默认发送该信号终止进程) + */ +static volatile std::sig_atomic_t _stop_requested = 0; + +void signalHandler(int signum) { + // 仅设置标志,不做其他操作(write 是 async-signal-safe 的,用于输出提示) + const char msg[] = "\n[ca] 收到关闭信号,准备退出...\n"; + ::write(STDERR_FILENO, msg, sizeof(msg) - 1); + _stop_requested = 1; +} + +void sigpipeHandler(int signum) { + // 忽略 SIGPIPE,write 调用会返回 EPIPE 错误而非终止进程 +} + +/* -------- 注册信号处理钩子 -------- */ +void set_signal_action() { + struct sigaction sa; + + // SIGINT / SIGTERM:安全设置关闭标志 + sa.sa_handler = signalHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + // SIGPIPE:忽略,防止写关闭的 socket 时进程意外终止 + struct sigaction sa_pipe; + sa_pipe.sa_handler = sigpipeHandler; + sigemptyset(&sa_pipe.sa_mask); + sa_pipe.sa_flags = 0; + sigaction(SIGPIPE, &sa_pipe, nullptr); +} + +/* -------- 入口 --------*/ +int main(int argc, char **argv) { + + /* 首先处理命令行参数 */ + po::options_description desc("cpp language web ca (central auth)"); + desc.add_options() + ("help,h", "output help message") + ("version,v", "output version message") + ; + + + /* 执行解析(类似) */ + po::variables_map vm; + { + try { + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + } + + catch (const std::exception &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "using --help to check options message" << std::endl; + throw e; + } + } + + + /* 分支:拉起帮助页面 */ + if (vm.count("help")) { + std::cout << desc << std::endl; + return 0; + } + + /* 分支:输出版本号 */ + if (vm.count("version")) { + std::cout << "CA " << _VER << std::endl; + return 0; + } + + /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== + * 与 Python 服务(dnfs/db/static)保持一致的加载模式: + * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 + * 2. 从 confs 拉取 ca.ini 全文(retry_on_refused=true 等待 confs 就绪) + * 3. 用 boost::property_tree 解析 ini + * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) + * 5. 配置完整性校验:必需字段缺失则启动失败 + * 不再支持本地 ini 文件回退(与 static 模块一致,配置统一由 confs 管理)。 + * ==================================================================================== */ + + { + /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ + _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); + _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + + ConfIni cf; + try { + /* 从 confs 拉取 ca.ini */ + std::cout << "[ca] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT + << ") 拉取 ca.ini ..." << std::endl; + pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "ca"); + json::object req; + req["_route"] = "get"; + req["name"] = "ca.ini"; + std::string resp_str = confs_client.call( + json::serialize(req), /*timeout=*/5.0, + /*retry_on_refused=*/true); + auto resp = json::parse(resp_str).as_object(); + if (!resp.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = resp.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[ca] 致命错误: 从 confs 获取 ca.ini 失败: " << err << std::endl; + return 1; + } + std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); + cf = ConfIni::fromString(ini_content); + std::cout << "[ca] 配置来源: confs (ca.ini @ " + << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; + } + catch (ConfIni::IniConfigureFileParseException &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "ini parse error" << std::endl; + return 1; + } + catch (const std::exception &e) { + std::cerr << "[ca] 致命错误: 配置加载失败: " << e.what() << std::endl; + return 1; + } + + /* 2. 从 ini 读取配置 */ + _ADDR = cf.get("Server.address", ""); + _PORT = cf.get("Server.port", -1); + _TLS = cf.get("Server.tls", false); + _UPSTREAM_GATEWAY = cf.get("Upstream.gateway", "http://127.0.0.1:8445"); + _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", ""), ';'); + + /* 下游服务地址 */ + _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); + _DB_PORT = cf.get("Downstream.db_port", 8005); + _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); + _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); + _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); + _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); + + /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ + { + std::string ws_cfg = cf.get("WsProxy.routes", ""); + if (!ws_cfg.empty()) { + auto entries = split(ws_cfg, ';'); + for (auto& entry : entries) { + auto eq = entry.find('='); + if (eq != std::string::npos) { + std::string path = entry.substr(0, eq); + std::string url = entry.substr(eq + 1); + auto trim = [](std::string& s) { + while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); + while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); + }; + trim(path); trim(url); + if (!path.empty() && !url.empty()) { + _WS_PROXY_ROUTES[path] = url; + } + } + } + } + } + + /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ + std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); + if (!env_listen_addr.empty()) _ADDR = env_listen_addr; + int env_listen_port = ConfIni::env_int("LISTEN_PORT", -1); + if (env_listen_port > 0) _PORT = env_listen_port; + + std::string env_upstream = ConfIni::env("UPSTREAM_GATEWAY"); + if (!env_upstream.empty()) _UPSTREAM_GATEWAY = env_upstream; + + std::string env_db_host = ConfIni::env("DB_HOST"); + if (!env_db_host.empty()) _DB_HOST = env_db_host; + int env_db_port = ConfIni::env_int("DB_PORT", -1); + if (env_db_port > 0) _DB_PORT = env_db_port; + + std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); + if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; + int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); + if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; + + std::string env_smtp_host = ConfIni::env("SMTP_HOST"); + if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; + int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); + if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; + + /* 4. 配置完整性校验 */ + if (_ADDR.empty() || _PORT == -1) { + std::cerr << "[ca] 致命错误: 配置项 [Server] address/port 缺失" + "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; + return 1; + } + if (_UPSTREAM_GATEWAY.empty()) { + std::cerr << "[ca] 致命错误: 配置项 [Upstream] gateway 缺失" << std::endl; + return 1; + } + if (_DB_HOST.empty() || _DB_PORT <= 0) { + std::cerr << "[ca] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; + return 1; + } + } + + /* 初始化下游客户端地址 */ + DbC::init(_DB_HOST, _DB_PORT); + logs().set_endpoint(_DB_HOST, _DB_PORT); + + std::cout << "[ca] 监听: " << _ADDR << ":" << _PORT + << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[ca] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT + << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT + << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; + std::cout << "[ca] 上游 gateway: " << _UPSTREAM_GATEWAY << std::endl; + + // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 + // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 + if (_TLS) { + _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); + } else { + _SERVE = std::make_unique (_ADDR, _PORT, 4); + } + _KEYGEN = std::make_unique (); + //_SQLDB = std::make_unique (_DB_PATH); + + /* 迁移到了外部 */ + //db_init(); + + /* */ + registerRoutes(*_SERVE); + + /* */ + set_signal_action(); + + std::cout << "CA " << _VER << " starting..." << std::endl; + + /* 在独立线程中运行服务器(start + run) + * 原因: + * 1. 信号处理函数只能安全设置 atomic 标志,不能直接调用 stop() + * 2. 主线程轮询检测关闭信号后,在安全上下文调用 stop() + * 3. 避免在信号处理函数中操作非 async-signal-safe 的 io_context、锁、iostream + */ + std::atomic server_exited{false}; + std::thread server_thread([&]() { + try { + _SERVE->run(); // run() 内部会调用 start() 启动 listener 和 worker 线程 + } catch (const std::exception& e) { + std::cerr << "[ca] Server thread exception: " << e.what() << std::endl; + } + server_exited = true; + }); + + // 等待服务器启动完成(简单延迟,确保started日志打印后再输出提示) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + std::cout << "CA " << _VER << " started" << std::endl; + logs().info("ca", std::string("CA ") + _VER + " started"); + + /* 主线程:等待关闭信号,带超时轮询 */ + while (!_stop_requested && !server_exited) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + + /* 收到信号或服务器异常退出:安全执行关闭流程 */ + if (!server_exited && _SERVE) { + std::cerr << "[ca] 正在停止服务器..." << std::endl; + + // 给服务器 5 秒时间优雅关闭,防止挂死 + std::thread stop_thread([&]() { + try { + _SERVE->stop(); + } catch (const std::exception& e) { + std::cerr << "[ca] Stop exception: " << e.what() << std::endl; + } + }); + + // 最多等待 8 秒,超时则 detach 让系统回收(极端情况下保证进程退出) + if (stop_thread.joinable()) { + auto future = std::async(std::launch::async, &std::thread::join, &stop_thread); + if (future.wait_for(std::chrono::seconds(8)) == std::future_status::timeout) { + std::cerr << "[ca] 警告:服务器停止超时,强制退出" << std::endl; + stop_thread.detach(); + std::exit(1); // 极端情况:8秒还没停完,强制退出(日志可能丢失但进程不挂死) + } + } + } + + if (server_thread.joinable()) { + server_thread.join(); + } + + /* 收到停止信号后的收尾:记录并 flush 剩余日志(最多等待3秒) */ + std::cerr << "[ca] 正在刷新日志..." << std::endl; + logs().info("ca", "CA shutdown"); + { + std::thread log_flush_thread([]() { + logs().shutdown(); + }); + auto future = std::async(std::launch::async, &std::thread::join, &log_flush_thread); + if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { + std::cerr << "[ca] 警告:日志刷新超时,退出" << std::endl; + log_flush_thread.detach(); + } + /* 非超时分支不得再 join:async 已完成 join,线程已不可 join, + * 重复 join 抛 std::system_error 导致 terminate(审计 A1) */ + } + + std::cerr << "[ca] 已退出" << std::endl; + _SERVE.reset(); + _KEYGEN.reset(); +} + +//void db_init() { +// +// std::string sql = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Users'"; +// +// auto flag = false; +// { +// auto stmt = _SQLDB->stmt(sql); +// if (stmt.step()) { +// auto num = stmt.read_int(0); +// if (num == 0) { +// flag = true; +// } +// } +// } +// +// if (flag) { +// std::cout << "Start database init..." << std::endl; +// std::string sql_crtt = +// "CREATE TABLE Users (\n" +// "id INTEGER PRIMARY KEY AUTOINCREMENT,\n" +// "email TEXT UNIQUE NOT NULL,\n" +// "password_hash TEXT NOT NULL,\n" +// "create_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" +// "last_login DATETIME,\n" +// "is_active BOOLEAN DEFAULT 1\n" +// ");\n\n" +// "CREATE INDEX idx_users_email ON users(email);\n"; +// +// _SQLDB->exec(sql_crtt); +// +// // No hardcoded admin - first registered user becomes admin +// std::cout << "done (no default admin - register manually)" << std::endl; +// } +// +// else { +// std::cout << "The database already init" << std::endl; +// } +//} + + +/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。 + * 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */ +static bool verifyWsAdminToken(const http::request& req, + const std::string& target_url, + std::string& out_target) { + out_target.clear(); + try { + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (raw_token.empty()) return false; + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) return false; + + // 必须是管理员 + if (!ti.is_admin) { + logs().warning("ca", "WS proxy rejected: non-admin user", + std::nullopt, std::string(req.target())); + return false; + } + + out_target = target_url; + return true; + } + catch (const std::exception& e) { + std::cerr << "[WS proxy auth error] " << e.what() << std::endl; + return false; + } +} + +/* 注册http路由 */ +void registerRoutes(HttpServer &server) { + + /* const http::request& req, const std::unordered_map& params */ + server.post("/login", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) {return handleLogin(req, params);}); + }); + + server.post("/register", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleRegister(req, params); }); + }); + + /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); + }); + + /* 发送重置密码邮箱验证码(须在 post("*") 通配符之前注册) */ + server.post("/reset/send_code", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleResetSendCode(req, params); }); + }); + + /* 重置密码(须在 post("*") 通配符之前注册) */ + server.post("/reset_password", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleResetPassword(req, params); }); + }); + + server.post("/logout", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](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); }); + }); + + server.post("/whoami", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleWhoami(req, params); }); + }); + + /* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */ + server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleAdminVerify(req, params); }); + }); + + /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ + server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleCaptchaImage(req, params); }); + }); + + server.get("*", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleForward(req, params, "get"); }); + }); + + server.post("*", [](const auto& req, const auto& params) { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleForward(req, params, "post"); }); + }); + + /* WebSocket 反向代理(仅管理员): + * ws_proxy 回调在 HTTP Upgrade 握手阶段触发, + * 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */ + for (const auto& [path, url] : _WS_PROXY_ROUTES) { + server.ws_proxy(path, [url](const http::request& upgradeReq) -> std::string { + std::string target; + if (verifyWsAdminToken(upgradeReq, url, target)) { + return target; + } + return ""; + }); + std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl; + } + +} + +/* Http视图包装器 + * + * 该函数收集视图函数抛出的异常 将信息发送给浏览器。这样视图函数有问题就可以直接抛出异常。 */ +http::response wrapperHttp( + const http::request& req, + const std::unordered_map& params, + HttpRequestHandler _main) + +try { return _main(req, params); } + + +/* 主动抛出的异常 */ +catch (const HttpException& e) { + http::response res; + res.version(req.version()); + // 详细错误信息(e.what() 含源码路径/行号/C++ 模板类型)暂时以注释方式保留(同 handleLogin) + std::cerr << "[wrapperHttp HttpException] " << e.what() << std::endl; + // 推送集中式日志:5xx 服务端错误记 ERROR,其余(400/401/403/429 等业务错误)记 WARNING + if (e.status() >= 500) + logs().error("ca", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); + else + logs().warning("ca", e.what(), std::nullopt, + json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); + json::value jv_r = { + {"success", false}, + /* 客户端只给纯消息(e.message());e.what() 的 [Http][Error NNNN] 内部前缀 + * 仅保留在服务端 stderr/集中日志,不外泄(审计 A8) */ + {"message", e.message()} + }; + res.set(http::field::content_type, "application/json"); + res.result(e.status()); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 预料之外的异常 */ +catch (const std::exception& e) { + + http::response res; + res.version(req.version()); + const auto& ti = typeid(e); + // 详细错误信息(ti.name() 含 mangled 类型名、e.what() 含内部细节)暂时以注释方式保留 + // 原因:此处为 wrapperHttp 外层 catch,会捕获 handler 内未处理的 std::exception + // 透传到客户端会泄露 C++ 类型信息与内部路径(P0-3 遗漏点,已补全) + // 排障:完整信息仍输出到服务端 stderr + std::cerr << "[wrapperHttp std::exception] <" << ti.name() << "> " << e.what() << std::endl; + logs().error("ca", std::string("<") + ti.name() + "> " + e.what()); + json::value jv_r = { + {"success", false}, + /* {"message", std::string("<") + ti.name() + "> " + e.what()}, */ // 泄露源码信息,已禁用 + {"message", "Internal Server Error"} + }; + res.set(http::field::content_type, "application/json"); + res.result(http::status::internal_server_error); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 处理登陆 + * + * /login */ +http::response handleLogin( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + std::string client_ip = ""; + auto it_host = req.base().find("X-Forwarded-For"); + if (it_host != req.base().end()) + client_ip = std::string(it_host->value()); + else + client_ip = "direct"; + + if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) + THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); + + // parse json req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const boost::wrapexcept& e) { + THROW_EX_CUSTOM(HttpException, 2001, "Invalid_json_Argument", 400, "/login"); + } + + if (!jv.is_object()) THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument", 400, "/login"); + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400, + * 否则被 wrapper 兜底成 500(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); + } + + // CAPTCHA verification 验证码(对接 captcha pymsg 服务,图形码 Token 一次性消费) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it == obj.end() || captcha_ans_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/login"); + + std::string stored_hash = ""; + { + // Fetch stored hash - support both PBKDF2 and legacy SHA256 + //std::string sql = "SELECT password_hash FROM Users WHERE email=?;"; + //auto stmt = _SQLDB->stmt(sql); + //stmt.bind_text(1, username); + //if (stmt.step()) { + // stored_hash = std::string(reinterpret_cast(stmt.read_text(0))); + //} + + // 新版调用db模块(通过 MsgClient pymsg TCP 协议) + // 【保留】旧版 HTTP 调用方式: + // auto jv_db_r = DbC::call_http(jv_select); + json::value jv_select = { + {"action", "select"}, + {"table", "Users"}, + {"where", json::object{ + {"username", username}, + }} + }; + auto jv_db_r = DbC::call_msg(jv_select); + auto obj_r = jv_db_r.as_object(); + auto success = obj_r["success"].as_bool(); + if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); + auto obj_rd = obj_r.at("data").as_array(); + /* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500, + * 此处保持 stored_hash 为空,统一走下面的 401 分支 */ + if (!obj_rd.empty()) { + auto obj_ru = obj_rd.at(0).as_object(); + stored_hash = obj_ru.at("password_hash").as_string(); + } + } + + + + bool auth_ok = false; + if (!stored_hash.empty()) { + if (stored_hash.substr(0, 7) == "pbkdf2:") + // New PBKDF2 hash + auth_ok = PBKDF2Hasher::verify_password(password, stored_hash); + //else { + // Legacy SHA256 hash - verify then auto-migrate + //auto legacy_hash = SHA256::sha256(password); + //if (legacy_hash == stored_hash) { + // auth_ok = true; + // Auto-migrate to PBKDF2 + // auto new_hash = PBKDF2Hasher::hash_password(password); + // std::string sql_migrate = "UPDATE Users SET password_hash=? WHERE email=?;"; + // auto stmt_mig = _SQLDB->stmt(sql_migrate); + // stmt_mig.bind_text(1, new_hash); + // stmt_mig.bind_text(2, username); + // stmt_mig.step(); + // std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl; + //} + } + else { + /* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序, + * 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */ + static const std::string dummy_hash = + "pbkdf2:100000:00000000000000000000000000000000:" + "0000000000000000000000000000000000000000000000000000000000000000"; + PBKDF2Hasher::verify_password(password, dummy_hash); + } + + if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login"); + + // Update last_login + //std::string sql_update = "UPDATE Users SET last_login=datetime('now','localtime') WHERE email=?;"; + //auto stmt_upd = _SQLDB->stmt(sql_update); + //stmt_upd.bind_text(1, username); + //stmt_upd.step(); + // + // 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间 + //(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。 + json::value jv_upd = { + {"action", "update"}, + {"table", "Users"}, + {"values", json::object{ + {"last_login", "$now"}, + }}, + {"where", json::object{ + {"username", username}, + }} + }; + auto jv_upd_r = DbC::call_msg(jv_upd); + if (!jv_upd_r.as_object()["success"].as_bool()) + THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); + + + /* 生成令牌 */ + 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(); + + /* Token 吊销支持:签发新 jti + 当前版本号 */ + std::string jti = generate_jti(); + int user_ver = get_user_ver(username); + + json::value jv_t = { + {"user", username}, + {"jti", jti}, + {"ver", user_ver}, + {"iat", LocalTime::getCurrent()}, + {"created_at", LocalTime::getCurrent()}, + {"keep_time", _TOKEN_KEEP_TIME}, + {"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); + std::string sign = _PRIV.Sign(data); + std::string token = auth + "_" + sign; + + + /* 生成返回json TODO: 专门写个类做这事 */ + json::value jv_rr = { + {"success", true}, + {"data", json::object{{ + "token", token}} + } + }; + res.set("Connection", "close"); + + /* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */ + { + std::string ck = "q4_token=" + token + + "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + + std::to_string(_TOKEN_KEEP_TIME); + res.set("Set-Cookie", ck); + } + + logs().info("ca", "登录成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + res.result(http::status::ok); + res.body() = json::serialize(jv_rr); + res.prepare_payload(); + return res; +} + +/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */ +http::response handleLogout( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (!raw_token.empty()) { + TokenInfo ti = verify_token(raw_token); + if (ti.valid && !ti.jti.empty()) { + revoke_jti(ti.jti, ti.keep_time); + logs().info("ca", "Token 已吊销: " + ti.username, std::nullopt, + json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}})); + } + } + + res.set("Set-Cookie", + "q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"); + res.set("Connection", "close"); + res.result(http::status::ok); + res.body() = R"({"success": true, "message": "logged out"})"; + res.prepare_payload(); + return res; +} + +/* 当前用户身份查询 /whoami + * + * 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。 + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 + * 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + res.set(http::field::content_type, "application/json"); + res.set("Connection", "close"); + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + + if (raw_token.empty()) { + res.result(http::status::ok); + res.body() = R"({"login": false})"; + res.prepare_payload(); + return res; + } + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) { + res.result(http::status::ok); + res.body() = R"({"login": false})"; + res.prepare_payload(); + return res; + } + + json::value jv_r = { + {"login", true}, + {"user", ti.username}, + {"is_admin", ti.is_admin} + }; + + /* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */ + if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat; + if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at; + if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time; + + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 内部服务间管理员验签视图:POST /admin/verify + * + * 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。 + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 + * ca 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。 + * + * 响应:{ success, is_admin, user } + * - success=true, is_admin=true → 有效管理员(放行) + * - success=true, is_admin=false → 非管理员(拒绝) + * - success=false → 无 Token / 验签失败 / 过期(拒绝) + * 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */ +http::response handleAdminVerify( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + res.set(http::field::content_type, "application/json"); + res.set("Connection", "close"); + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + + if (raw_token.empty()) { + res.result(http::status::unauthorized); + res.body() = R"({"success": false, "is_admin": false})"; + res.prepare_payload(); + return res; + } + + /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + TokenInfo ti = verify_token(raw_token); + if (!ti.valid) { + res.result(http::status::unauthorized); + res.body() = R"({"success": false, "is_admin": false})"; + res.prepare_payload(); + return res; + } + + json::value jv_r = { + {"success", true}, + {"is_admin", ti.is_admin}, + {"user", ti.username} + }; + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + 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"); + + /* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */ + std::string cookie_token; + std::string raw_token = extract_token_from_request(req, cookie_token); + if (raw_token.empty()) + THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token"); + + /* 正常过期(非吊销/版本错误)才允许宽限期刷新: + * - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝 + * - 版本号落后(改密后旧token)→ 401 拒绝 + * 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。 + * 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。 + * 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */ + + // 重新做一次不含过期检查的验签(判断是否被吊销) + bool token_revoked = false; + std::string old_jti; + std::string username; + std::string iat_str; + int keep_time = _TOKEN_KEEP_TIME; + int refresh_count = 0; + int max_refresh = _TOKEN_MAX_REFRESH; + bool is_admin = false; + int user_ver = 1; + { + if (raw_token.find("_") == std::string::npos) + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + size_t idx2 = raw_token.find("_"); + std::string data = raw_token.substr(0, idx2); + std::string sign = raw_token.substr(idx2 + 1); + std::string auth; + try { auth = Base64::base64_decode_to_string(data); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); + } + auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); + if (!_PUBL.Verify(auth, sign)) + THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token"); + json::value jv_t; + try { jv_t = json::parse(auth); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + } + if (!jv_t.is_object()) + THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); + json::object& obj = jv_t.as_object(); + username = json::value_to(obj.at("user")); + iat_str = json::value_to(obj.at("iat")); + keep_time = json::value_to(obj.at("keep_time")); + refresh_count = json::value_to(obj.at("refresh_count")); + max_refresh = json::value_to(obj.at("max_refresh")); + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) + is_admin = it_admin->value().as_bool(); + // 检查 jti 黑名单 + auto it_jti = obj.find("jti"); + if (it_jti != obj.end() && it_jti->value().is_string()) { + old_jti = json::value_to(it_jti->value()); + std::lock_guard lock(_TOKEN_REVOKE_MTX); + if (_JTI_DENYLIST.count(old_jti)) + token_revoked = true; + } + // 检查版本号 + auto it_ver = obj.find("ver"); + int token_ver = 1; + if (it_ver != obj.end() && it_ver->value().is_int64()) + token_ver = static_cast(it_ver->value().as_int64()); + { + std::lock_guard lock(_TOKEN_REVOKE_MTX); + auto it_uv = _USER_VER_MAP.find(username); + user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; + if (token_ver < user_ver) + token_revoked = true; + } + } + + if (token_revoked) + THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.", + 401, "/refresh_token"); + + /* 刷新次数检查 */ + 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"); + + /* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */ + if (!old_jti.empty()) { + revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); + } + + /* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */ + std::string new_jti = generate_jti(); + auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); + json::value jv_new = { + {"user", username}, + {"jti", new_jti}, + {"ver", user_ver}, + {"iat", iat_str}, + {"created_at", LocalTime::getCurrent()}, + {"keep_time", keep_time}, + {"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("ca", "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),浏览器无法直接访问, + * 由本路由做 HTTP → pymsg 转换:调 image 路由拿到 base64 PNG 与 token, + * 解码后以 image/png + X-Captcha-Token 响应头返回(与旧直出接口契约一致)。 + */ +http::response handleCaptchaImage( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + /* IP 级速率限制(P1-1 修复):单 IP 每分钟最多 10 次, + 防止单一攻击者打满全局限流导致全站验证码不可用 */ + 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 (!_CAPTCHA_IMAGE_RATE.check_and_record(client_ip, 10, 60)) + THROW_EX_CUSTOM(HttpException, 2403, + "Too many captcha requests. Please try again later.", 429, "/captcha/image"); + + /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "ca"); + std::string resp; + try { + resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); + } catch (const std::exception& e) { + THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); + } + + /* 解析响应(格式错误同样视为 502) */ + std::string token; + std::string png; + try { + auto jv = json::parse(resp); + auto& obj = jv.as_object(); + if (!obj.at("success").as_bool()) { + // P3-5: 依据 error 信息映射准确 HTTP 状态码 + std::string err_msg = json::value_to(obj.at("error")); + if (err_msg.find("rate_limit") != std::string::npos) + THROW_EX_CUSTOM(HttpException, 2401, "Too many captcha requests", 429, "/captcha/image"); + if (err_msg.find("store") != std::string::npos || err_msg.find("full") != std::string::npos) + THROW_EX_CUSTOM(HttpException, 2401, "Captcha service overloaded", 503, "/captcha/image"); + THROW_EX_CUSTOM(HttpException, 2401, "Captcha service error", 502, "/captcha/image"); + } + auto& data = obj.at("data").as_object(); + token = json::value_to(data.at("token")); + auto image_b64 = json::value_to(data.at("image")); + png = Base64::base64_decode_to_string(image_b64); + } catch (const HttpException&) { + throw; + } catch (const std::exception& e) { + THROW_EX_CUSTOM(HttpException, 2402, "Bad captcha response", 502, "/captcha/image"); + } + + res.set(http::field::content_type, "image/png"); + res.set("X-Captcha-Token", token); + res.set("Cache-Control", "no-store"); /* 验证码图片禁止缓存 */ + res.set("Connection", "close"); + res.result(http::status::ok); + res.body() = std::move(png); + res.prepare_payload(); + return res; +} + +/* 处理注册 /register */ +http::response handleRegister( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + 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 (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) + THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", + 429, "/register"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const boost::wrapexcept& e) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); + + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 400, "/register"); + } + + // Strong password check + auto pw_result = PasswordValidator::validate(password); + if (!pw_result.valid) + THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/register"); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(username, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register"); + + // CAPTCHA verification(可选;提交则强制校验,走 captcha 服务远端渠道,审计 A6) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register"); + } + + // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) + auto ecode_it = obj.find("email_code"); + if (ecode_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register"); + std::string email_code = std::string(ecode_it->value().as_string()); + if (!verify_email_code_remote(username, email_code)) + THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/register"); + + // generate the hash of password + auto password_hash = PBKDF2Hasher::hash_password(password); + + //std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));"; + //auto stmt = _SQLDB->stmt(sql); + //stmt.bind_text(1, username); + //stmt.bind_text(2, password_hash); + //stmt.step(); + // + // 新版调用db模块(通过 MsgClient pymsg TCP 协议) + // 【保留】旧版 HTTP 调用方式: + // auto jv_result = DbC::call_http(jv_insert); + json::value jv_insert = { + {"action", "insert"}, + {"table", "Users"}, + {"values", json::object{ + {"username", username}, + {"password_hash", password_hash} + }} + }; + auto jv_result = DbC::call_msg(jv_insert); + auto obj_r = jv_result.as_object(); + auto success = obj_r["success"].as_bool(); + if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registration failed, please try again later", 500, "/register"); + auto jv_r = json::value{{"success", success}}; + + logs().info("ca", "注册成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 处理发送注册邮箱验证码 /register/send_code + * + * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 + * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ +http::response handleSendEmailCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + 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 (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.", + 429, "/register/send_code"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + + auto& obj = jv.as_object(); + auto email_it = obj.find("email"); + if (email_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); + auto email = std::string(email_it->value().as_string()); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(email, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); + + // CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1) + // captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。 + // fail-closed:captcha 服务不可用或响应异常一律视为校验失败。 + auto captcha_token_it = obj.find("captcha_token"); + auto captcha_answer_it = obj.find("captcha_answer"); + if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code"); + if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), + std::string(captcha_answer_it->value().as_string()))) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code"); + + // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) + // 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。 + if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) + THROW_EX_CUSTOM(HttpException, 3104, + "Too many code requests for this email. Please try again later.", + 429, "/register/send_code"); + if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) + THROW_EX_CUSTOM(HttpException, 3105, + "Daily email limit exceeded. Please try again tomorrow.", + 429, "/register/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送验证码 + bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email} + }; + 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()) { + 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) { + // 同邮箱发送冷却:按限流语义返回 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("ca", "验证码邮件发送成功: " + email, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + auto jv_r = json::value{{"success", true}}; + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 发送重置密码邮箱验证码 /reset/send_code + * + * 与注册发码(/register/send_code)结构一致,区别在定制"重置密码"主题与文案: + * 经 pymsg 调 smtp 服务 send_email 路由(subject/content 字段覆盖默认注册文案)。 + * 限流 5 次/10 分钟/IP;CAPTCHA 强制校验;smtp 不可用返回 502(fail-closed)。 */ +http::response handleResetSendCode( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + 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 (!_RESET_CODE_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3201, "Too many code requests. Please try again later.", + 429, "/reset/send_code"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); + + auto& obj = jv.as_object(); + auto email_it = obj.find("email"); + if (email_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); + auto email = std::string(email_it->value().as_string()); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(email, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset/send_code"); + + // CAPTCHA 强制校验(防邮件轰炸,与 /register/send_code 一致) + // 经 captcha:8009 pymsg 校验,Token 一次性消费;fail-closed。 + auto captcha_token_it = obj.find("captcha_token"); + auto captcha_answer_it = obj.find("captcha_answer"); + if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/reset/send_code"); + if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), + std::string(captcha_answer_it->value().as_string()))) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset/send_code"); + + // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) + // 与注册发码共用同一配额,防止重置/注册双通道叠加绕过邮箱轰炸防护 + if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) + THROW_EX_CUSTOM(HttpException, 3104, + "Too many code requests for this email. Please try again later.", + 429, "/reset/send_code"); + if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) + THROW_EX_CUSTOM(HttpException, 3105, + "Daily email limit exceeded. Please try again tomorrow.", + 429, "/reset/send_code"); + + // 调 smtp 服务(pymsg/TCP :8008)发送重置密码验证码(定制文案) + bool sent = false; + int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 + std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) + try { + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "ca"); + json::value jv_req = { + {"_route", "send_email"}, + {"email", email}, + {"subject", "QSONT.me - Password Reset"}, + {"content", "您正在重置 QSONT.me 账号密码,验证码为 {code},10 分钟内有效。如非本人操作,请忽略本邮件。"} + }; + 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()) { + 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, "/reset/send_code"); + } + + 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, "/reset/send_code"); + THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/reset/send_code"); + } + + logs().info("ca", "重置密码验证码邮件发送成功: " + email, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + auto jv_r = json::value{{"success", true}}; + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 处理重置密码 /reset_password + * + * 与注册流程一致,最后一步由"创建用户"改为"更新密码": + * 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ 邮箱验证码(smtp verify_email,一次性) + * → 生成 PBKDF2 哈希 → UPDATE Users SET password_hash WHERE username + * 防枚举:用户不存在(update 匹配 0 行)时仍统一返回成功,不泄露邮箱是否已注册。 + * CAPTCHA 走 captcha:8009 远端校验(与前端 /captcha/image 的 token 匹配)。 + */ +http::response handleResetPassword( + const http::request& req, + const std::unordered_map& params) +{ + http::response res; + res.version(req.version()); + + // IP rate limit check + 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 (!_RESET_PASSWORD_RATE.check_and_record(client_ip, 5, 600)) + THROW_EX_CUSTOM(HttpException, 3301, "Too many reset attempts. Please try again later.", + 429, "/reset_password"); + + // parse the req body + json::value jv; + try { jv = json::parse(req.body()); } + catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); + } + + if (!jv.is_object()) + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); + + auto& obj = jv.as_object(); + /* 缺字段时 obj.at() 抛 std::out_of_range,须就地捕获返回 400(审计 A3) */ + std::string username, password; + try { + username = std::string(obj.at("username").as_string()); + password = std::string(obj.at("password").as_string()); + } catch (const std::exception&) { + THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 400, "/reset_password"); + } + + // Strong password check + auto pw_result = PasswordValidator::validate(password); + if (!pw_result.valid) + THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/reset_password"); + + // Email format check + std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(username, email_regex)) + THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset_password"); + + // CAPTCHA verification(可选,与注册一致;走 captcha 服务远端校验) + auto captcha_it = obj.find("captcha_token"); + auto captcha_ans_it = obj.find("captcha_answer"); + if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { + std::string ct = std::string(captcha_it->value().as_string()); + std::string ca = std::string(captcha_ans_it->value().as_string()); + if (!verify_captcha_remote(ct, ca)) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset_password"); + } + + // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) + auto ecode_it = obj.find("email_code"); + if (ecode_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/reset_password"); + std::string email_code = std::string(ecode_it->value().as_string()); + if (!verify_email_code_remote(username, email_code)) + THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/reset_password"); + + // generate the hash of password + auto password_hash = PBKDF2Hasher::hash_password(password); + + // 更新密码:UPDATE Users SET password_hash=? WHERE username=?(经 db 服务 pymsg execute) + json::value jv_update = { + {"action", "update"}, + {"table", "Users"}, + {"values", json::object{ + {"password_hash", password_hash} + }}, + {"where", json::object{ + {"username", username} + }} + }; + auto jv_result = DbC::call_msg(jv_update); + auto obj_r = jv_result.as_object(); + if (!obj_r["success"].as_bool()) + THROW_EX_CUSTOM(HttpException, 2202, "Password reset failed, please try again later", 500, "/reset_password"); + + /* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */ + revoke_all_user_tokens(username); + + // 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册 + logs().info("ca", "重置密码成功: " + username, std::nullopt, + json::serialize(json::value{{"ip", client_ip}})); + + auto jv_r = json::value{{"success", true}}; + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + +/* 前置转发层(waf → ca → gateway 拓扑) + * + * 语义: + * - 无 Target-Url 头 = 公开请求(前端静态资源等):剥离伪造身份头后直转 gateway + * - 有 Target-Url 头 = 受保护 API 请求:本地验签(RSA 密钥同进程,零网络跳), + * 通过后注入可信身份头 X-Auth-User / X-Auth-Admin / Authorization 再转发 + * - X-Auth-* 采用 strip-then-set 契约:is_special_header 先剥离客户端伪造值 + * - 上游 gateway 不可达统一返回 502(区别于 CA 自身故障的 500) + */ +http::response handleForward( + const http::request& req, + const std::unordered_map& params, + const char *method) +{ + http::response res; + res.version(req.version()); + + /* 显式携带 Target-Url 头(任意大小写)的视为受保护 API 请求 */ + bool is_protected = + req.base().find("Target-Url") != req.base().end() || + req.base().find("target-url") != req.base().end(); + + // 路径规范化校验(P1-1):防止路径遍历、协议注入、空字节等 + // 校验在 url 拼接前完成,避免恶意 target 污染下游请求 + std::string target_path = std::string(req.target()); + if (!is_safe_target(target_path)) + THROW_EX_CUSTOM(HttpException, 2300, "Invalid request path", 400, "*"); + + /* 固定上游:前置拓扑下所有流量统一转发到 gateway(Target-Url 头原样透传, + * 由 gateway 按自身白名单做内部路由) */ + std::string url = _UPSTREAM_GATEWAY; + /* 避免 target_path 以 "/" 开头时产生双斜杠(如 http://host:port//list), + * 下游服务(core 等)使用精确路径匹配,双斜杠会导致 404 */ + if (target_path.empty() || target_path[0] != '/') + url += "/"; + url += target_path; + + /* 提取 Token(Authorization 优先,回退 Cookie) */ + std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */ + std::string raw_token = extract_token_from_request(req, cookie_token); + + TokenInfo ti; + if (is_protected) { + /* 受保护请求:统一验签(RSA + 过期 + jti黑名单 + 版本号) */ + if (raw_token.empty()) + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + + ti = verify_token(raw_token); + if (!ti.valid) { + if (ti.http_status == 400) + THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*"); + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + } + } else { + /* 公开请求不做凭证校验,也不向下游注入身份头 */ + cookie_token = ""; + } + + /* + * HTTP客户端回调函数 + * 响应体大小限制由 HttpClient 默认 10MB 上限保证(libhttp P1-2 已修复) */ + auto callback = [&res](const auto& ret) { + res.body() += ret; + }; + + /* 复制(几乎)所有的请求头 */ + auto client = HttpClient(); + for (auto const& field: req) { + + if (is_special_header(field.name_string())) continue; + auto h = std::string(); + h += field.name_string(); + h += ": "; + h += field.value(); + client.set_header(h); + } + + // 主动设置可信的 X-Forwarded-For(P1-1) + // 说明:is_special_header 已剥离客户端可能伪造的 XFF/X-Real-IP/Forwarded + // WAF → Gateway 为可信内网链路,收到的 XFF 即客户端真实 IP + // 下游服务应信任此值做限流/审计,不再被客户端伪造 + auto xff_it = req.base().find("X-Forwarded-For"); + if (xff_it != req.base().end()) { + client.set_header(std::string("X-Forwarded-For: ") + std::string(xff_it->value())); + } else { + // WAF 未传 XFF 时,标记为 unknown,避免下游拿到空值 + client.set_header("X-Forwarded-For: unknown"); + } + + /* 验签通过:注入可信身份头(strip-then-set 契约,gateway/下游直接信任) */ + if (is_protected) { + client.set_header("X-Auth-User: " + ti.username); + if (ti.is_admin) + client.set_header("X-Auth-Admin: 1"); + /* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动); + * 来自 Authorization 头时上面请求头复制环节已透传 */ + if (!cookie_token.empty()) { + client.set_header("Authorization: Bearer " + cookie_token); + } + } + + /* 转发请求;上游 gateway 不可达/超时统一 502(区别于 CA 自身故障的 500) */ + try { + if (method == "get") + client.get_stream_sync(url, callback); + + else if (method == "post") { + std::string payload = req.body(); + client.post_stream_sync(url, payload, callback); + } + } catch (const std::exception& e) { + logs().error("ca", "上游 gateway 转发失败: " + url, std::nullopt, e.what()); + THROW_EX_CUSTOM(HttpException, 2304, "Bad Gateway", 502, "*"); + } + + /* 复制所有的响应头 */ + for (const auto& pair: client.get_response_headers() ) + res.set(pair.first, pair.second); + + + res.set("Connection", "close"); + res.result(client.get_response_code()); + + res.prepare_payload(); + return res; +} + +bool is_special_header(const std::string &header) { + + // 原过滤:传输层/编码相关,由 HttpClient 自行处理 + // 新增过滤(P1-1):客户端可伪造的 IP 相关头 + // - X-Forwarded-For / X-Real-IP / Forwarded + // 剥离后由 ca 主动设置可信值(见 handleForward 转发逻辑) + // 防止客户端伪造 IP 绕过下游限流/审计 + // A5 修复:Beast name_string() 保留线上原始大小写,比较前统一转小写, + // 否则小写 x-forwarded-for 等变体绕过剥离,与注入的可信 XFF 并存到达下游 + // CA 新增:x-auth-user / x-auth-admin 为 CA 专有注入的身份头, + // 必须剥离客户端伪造值(strip-then-set 契约) + std::string h; + h.reserve(header.size()); + for (unsigned char c: header) + h.push_back(static_cast(std::tolower(c))); + return h == "host" || h == "connection" + || h == "accept-encoding" + || h == "x-forwarded-for" + || h == "x-real-ip" + || h == "forwarded" + || h == "x-auth-user" + || h == "x-auth-admin"; +} + +std::string get_cookie_value(const std::string& cookie_header, const std::string& name) { + // Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx" + size_t pos = 0; + while (pos <= cookie_header.size()) { + size_t end = cookie_header.find(';', pos); + if (end == std::string::npos) end = cookie_header.size(); + std::string pair = cookie_header.substr(pos, end - pos); + size_t eq = pair.find('='); + if (eq != std::string::npos) { + std::string k = pair.substr(0, eq); + size_t b = k.find_first_not_of(" \t"); + size_t e = k.find_last_not_of(" \t"); + if (b != std::string::npos) k = k.substr(b, e - b + 1); + if (k == name) return pair.substr(eq + 1); + } + if (end == cookie_header.size()) break; + pos = end + 1; + } + return ""; +} + +bool is_safe_target(const std::string& target) { + // 请求路径安全性校验(P1-1) + // 防御目标:路径遍历、协议注入、空字节、非绝对路径形式 + + if (target.empty()) return false; + + // 必须以 '/' 开头(拒绝 "http://evil.com/..." 等绝对 URI 形式) + if (target[0] != '/') return false; + + // 拒绝空字节(防止截断绕过) + if (target.find('\0') != std::string::npos) return false; + + // 拒绝路径遍历:明文 ".." + if (target.find("..") != std::string::npos) return false; + + // 拒绝路径遍历:URL 编码形式(%2e / %2E,含 %2f / %5c 的编码变体) + std::string lower_target; + lower_target.reserve(target.size()); + for (char c : target) { + lower_target.push_back(static_cast(std::tolower(static_cast(c)))); + } + if (lower_target.find("%2e") != std::string::npos) return false; + if (lower_target.find("%2f") != std::string::npos) return false; + if (lower_target.find("%5c") != std::string::npos) return false; + + return true; +} diff --git a/confs/configs/ca.ini b/confs/configs/ca.ini new file mode 100644 index 0000000..3c8c190 --- /dev/null +++ b/confs/configs/ca.ini @@ -0,0 +1,51 @@ +[Server] +# CA(Central Auth)前置认证服务:接管原 gateway 的 8443 端口, +# WAF upstream 不变(waf → ca → gateway 前置拓扑)。 +# - tls = off 启用 HTTP 模式(配合 waf 模块使用) +# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +address = 127.0.0.1 +port = 8443 +tls = off + +[Upstream] +# 前置拓扑的唯一上游:认证通过后转发到 gateway(仅 loopback) +# 环境变量 UPSTREAM_GATEWAY 可覆盖 +gateway = http://127.0.0.1:8445 + +[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 等)凭该字段豁免所有权校验,无需各自维护名单。 +# 修改后需重启 ca 生效;旧 Token 无此字段,按非管理员处理。 +users = 1776177662@qq.com;277665039@qq.com; + +[CORS] +# 允许跨域的 Origin 白名单(分号分隔) +# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[WsProxy] +# WebSocket 反向代理路由(仅管理员可访问) +# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 +# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), +# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 +# 支持 ws:// 和 wss:// 后端 +# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws +routes = /ws/aibox=ws://127.0.0.1:8765; + +[Downstream] +# 下游 pymsg 服务地址(其他微服务的 host:port) +# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +db_host = 127.0.0.1 +db_port = 8005 +captcha_host = 127.0.0.1 +captcha_port = 8009 +smtp_host = 127.0.0.1 +smtp_port = 8008 diff --git a/confs/configs/gateway.ini b/confs/configs/gateway.ini index 18b972e..001877c 100644 --- a/confs/configs/gateway.ini +++ b/confs/configs/gateway.ini @@ -1,9 +1,11 @@ [Server] -# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: -# - tls = off 启用 HTTP 模式(配合 waf 模块使用) -# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +# v0.9.0:认证独立化(waf → ca → gateway 前置拓扑) +# gateway 退化为纯转发服务,仅 loopback 监听(启动强制自检,非 loopback 拒绝启动): +# - 8443 由 ca 接管(WAF upstream 不变),gateway 退居 8445 +# - 认证/验签/限流/WS 代理鉴权已全部迁至 ca(confs/configs/ca.ini) +# - tls = off 启用 HTTP 模式(ca 前置后的内部链路) address = 127.0.0.1 -port = 8443 +port = 8445 tls = off [Proxy] @@ -11,43 +13,8 @@ default = http://127.0.0.1:9200 closed = http://127.0.0.1:8005;http://127.0.0.1:8007; opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; -[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 = 1776177662@qq.com;277665039@qq.com; - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[Database] -path = sqlite3.db - -[WsProxy] -# WebSocket 反向代理路由(仅管理员可访问) -# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 -# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), -# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 -# 支持 ws:// 和 wss:// 后端 -# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws -routes = /ws/aibox=ws://127.0.0.1:8765; - [Downstream] -# 下游 pymsg 服务地址(其他微服务的 host:port) -# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +# 日志推送端点(db pymsg 服务) +# 环境变量可覆盖:DB_HOST/DB_PORT, CONFS_HOST/CONFS_PORT db_host = 127.0.0.1 db_port = 8005 -captcha_host = 127.0.0.1 -captcha_port = 8009 -smtp_host = 127.0.0.1 -smtp_port = 8008 diff --git a/core/init.txt b/core/init.txt index 1a8105f..a739ccd 100644 --- a/core/init.txt +++ b/core/init.txt @@ -5,6 +5,7 @@ bash /opt/micro_servers/dnfs2/_run.sh bash /opt/micro_servers/smtp/_run.sh bash /opt/micro_servers/fsys/_run.sh bash /opt/micro_servers/waf/_run.sh +bash /opt/micro_servers/ca/_run.sh bash /opt/micro_servers/gateway/_run.sh bash /opt/micro_servers/captcha2/_run.sh bash /opt/micro_servers/aibox/_run.sh diff --git a/docs/reports/auth_service_plan.md b/docs/reports/auth_service_plan.md index 7489be8..2aead12 100644 --- a/docs/reports/auth_service_plan.md +++ b/docs/reports/auth_service_plan.md @@ -1,136 +1,146 @@ -# 权限系统独立化计划书 +# 认证独立化(CA 模块)开发文档 -## 一、现状分析 +**日期**: 2026-08-09(v2 重写) +**背景**: gateway v0.8.0 单文件 [main.cpp](../../gateway/src/main.cpp)(2470 行),认证逻辑(约 65%)与反向代理耦合;core/dnfs2 通过 HTTP 回调 gateway 验签,gateway 既是流量入口又是认证权威,形成环形依赖 +**范围**: `ca`(新模块)、`gateway`(瘦身为纯代理);**WAF / core / dnfs2 / static / 前端零改动** +**前版**: 2026-08-08 初版计划书(auth 双栈方案)。本版按已选定拓扑重写:**数据流 `waf → ca → gateway`,gateway 不再验签**。原双栈/后置方案见 §2.2 决策记录 -### 1.1 当前架构 +--- + +## 0. 摘要 + +新建 CA(Central Auth)模块:C++20 服务,接管 gateway 当前监听的 `127.0.0.1:8443`(WAF upstream 不变),承载**全部认证面**(登录/注册/Token 生命周期/CAPTCHA 代理/限流/WS 代理),并作为**认证前置层**:对业务请求本地验签后注入可信身份头,转发给退居 `127.0.0.1:8445` 的 gateway。gateway 删除全部认证代码,不再验签,仅保留白名单反代。 + +核心收益:验签在 CA 本地完成(RSA 密钥同进程,**零新增网络跳**);core/dnfs2 仍调 `8443` 的 `/admin/verify`、`/whoami`,**无感知切换**;前端 URL 不变;现有黑盒 34 断言原样充当验收门。 + +--- + +## 1. 现状分析 + +### 1.1 当前调用链 ``` -客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ 下游服务 - ( auth + proxy + captcha + rate-limit + ws-proxy ) - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - db:8005 captcha:8009 smtp:8008 - (Users表) (图形验证码) (邮件发送) +客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ static:9200 / dnfs2:8006 / core:5200 + │ ├─ 本地验签(RSA 内存密钥)+ Cookie 回退 + Authorization 注入 + │ ├─ /login /register /logout /refresh_token /whoami /admin/verify + │ ├─ /register/send_code /reset/send_code /reset_password + │ ├─ GET /captcha/image(pymsg 代理 captcha2:8009) + │ └─ WS 代理 /ws/aibox(握手验签 is_admin) + └── pymsg: db:8005 / captcha2:8009 / smtp3:8008 / confs:8010 -下游鉴权调用链: - core:5200 ──HTTP POST──▶ gateway:/admin/verify (管理员验签) - dnfs2:8006 ──HTTP POST──▶ gateway:/whoami (Token 验签) +下游验签回调(HTTP 回环): + core:5200 ──HTTP──▶ gateway:8443/admin/verify (HttpTask.cpp,GATEWAY_HOST/GATEWAY_PORT 可配,fail-closed) + dnfs2:8006 ──HTTP──▶ gateway:8443/whoami (gateway_url 配置项,fail-closed) ``` -### 1.2 Gateway 职责清单(main.cpp 2508 行,单文件) +### 1.2 gateway 职责盘点与归属 -| 职责域 | 函数/组件 | 行数占比 | -|--------|-----------|---------| -| **用户认证** | handleLogin, handleRegister, handleLogout, handleResetPassword, handleSendEmailCode, handleResetSendCode, PBKDF2Hasher, PasswordValidator | ~40% | -| **Token 管理** | verify_token, generate_jti, revoke_jti, revoke_all_user_tokens, handleRefreshToken, handleWhoami, handleAdminVerify, TokenInfo, JtiDenylist, UserVerMap | ~25% | -| **CAPTCHA** | handleCaptcha, handleCaptchaImage, CaptchaEntry, generate_captcha_text, verify_captcha | ~8% | -| **速率限制** | 8 个 RateLimiter 实例(login/register/email/refresh/reset 等) | ~5% | -| **HTTP 反向代理** | handleProxy, is_special_header, is_safe_target | ~12% | -| **WebSocket 代理** | verifyWsAdminToken, ws_proxy 路由注册 | ~5% | -| **配置/基础设施** | main(), registerRoutes(), ConfIni, DbC, LogC, 信号处理 | ~5% | +| 职责域 | 函数/组件 | 归属 | +|--------|-----------|------| +| 用户认证 | `handleLogin` / `handleRegister` / `handleLogout` / `handleResetPassword` / `handleSendEmailCode` / `handleResetSendCode` | → CA | +| Token 管理 | `verify_token` / `generate_jti` / `revoke_jti` / `revoke_all_user_tokens` / `handleRefreshToken` / `handleWhoami` / `handleAdminVerify` / jti 黑名单 / 用户版本号表 / RSA 密钥对 | → CA | +| 密码体系 | `PBKDF2Hasher` / `PasswordValidator` / 防枚举 dummy 校验 | → CA | +| 验证码/邮箱 | `handleCaptchaImage` / `verify_captcha_remote` / `verify_email_code_remote` | → CA | +| 速率限制 | 全部 RateLimiter 实例(登录 5/5min、注册 3/10min、发码 5/10min + 邮箱级 3/h+10/d、验证码 10/min 等) | → CA | +| WS 代理 | `verifyWsAdminToken` + `_WS_PROXY_ROUTES` 注册循环 | → CA(验签+桥接一体) | +| Cookie | `q4_token` 签发/清除、`extract_token_from_request`(Cookie 回退)、代理时 `Authorization: Bearer` 注入 | → CA | +| HTTP 反代 | `handleProxy`(白名单/路径安全/头转发/XFF) | 留 gateway(去验签化) | +| 头卫生 | `is_special_header` / `is_safe_target` | 留 gateway | +| 基础设施 | `wrapperHttp` / `ConfIni` / `DbC` / `LogC` / 信号处理 / 优雅关闭 | 两侧各持一份(模板见 smtp3) | ### 1.3 核心问题 -1. **职责严重耦合**:auth(~65%代码量)与 proxy(~17%)捆绑在同一进程,任一域的重构/扩展都影响全局 -2. **不可独立扩展**:认证密集型场景(批量登录/注册)和代理密集型场景(大文件传输)无法分别扩容 -3. **安全隔离缺失**:密码哈希、RSA 私钥、Token 黑名单等敏感数据与代理逻辑共存于同一进程内存空间 -4. **下游耦合 gateway**:core/dnfs2 通过 HTTP 回调 gateway 做验签,gateway 既是流量入口又是认证权威,形成环形依赖 -5. **单文件维护瓶颈**:2508 行 C++ 单文件,所有功能交叉耦合,测试和 code review 成本高 -6. **Token 状态纯内存**:jti 黑名单和版本号在 gateway 重启后丢失(虽然 RSA 密钥同步重建可兜底,但限制了多实例部署) +1. **职责耦合**:认证与代理同进程,任一域改动爆炸半径为全文件 +2. **环形依赖**:gateway 是流量入口又是验签权威,core/dnfs2 须 HTTP 回调入口 +3. **敏感面混杂**:RSA 私钥、密码哈希校验、jti 黑名单与转发逻辑共用内存空间 +4. **不可独立扩展**:认证密集与代理密集负载无法分别扩容;认证状态纯内存,绑定单实例 --- -## 二、目标架构 +## 2. 目标架构 + +### 2.1 拓扑 ``` -客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ gateway:8443 ──HTTP──▶ 下游服务 - ( 纯反向代理 + Token 透传 ) - │ - ▼ - auth:8444 (独立权限服务) - ┌────┼────┐ - ▼ ▼ ▼ - db:8005 captcha smtp - -下游鉴权调用链(改造后): - core:5200 ──pymsg──▶ auth:8444:/verify (管理员验签) - dnfs2:8006 ──pymsg──▶ auth:8444:/whoami (Token 验签) - gateway ──pymsg──▶ auth:8444:/verify (代理转发前的 Token 校验) +客户端 ──HTTPS──▶ WAF:443 ──HTTP──▶ CA:8443 ──HTTP──▶ gateway:8445 ──▶ static:9200 / dnfs2:8006 / core:5200 + │ + ├─ 本地处理:/login /register /logout /refresh_token /whoami /admin/verify + │ /register/send_code /reset/send_code /reset_password + │ GET /captcha/image + ├─ WS:/ws/aibox 握手验签(is_admin) → 直接桥接 ws://127.0.0.1:8765 + ├─ 其余路径:本地验签 → 注入 X-Auth-User/Authorization → 转发 gateway:8445 + └─ pymsg: db:8005 / captcha2:8009 / smtp3:8008 / confs:8010 + +端口安排:CA 接管 8443(WAF upstream 配置不变);gateway 迁 8445(仅 loopback,仅 CA 可达)。 ``` -### 2.1 职责划分 +职责划分:**CA = 认证权威 + 认证前置代理**;**gateway = 纯路由/转发策略**(opened 白名单、路径安全、XFF 透传)。 -| 模块 | 改造后职责 | -|------|-----------| -| **auth(新)** | 用户注册/登录/注销、Token 签发/验签/刷新/吊销、CAPTCHA、密码重置、邮箱验证码、速率限制、管理员验证 | -| **gateway(精简)** | HTTP/WebSocket 反向代理、Target-Url 白名单过滤、XFF 注入、CORS 预检;认证路由(/login, /register 等)作为代理目标指向 auth | -| **core / dnfs2** | 验签调用目标由 gateway 改为 auth(pymsg 协议,消除 HTTP 回调) | +### 2.2 决策记录 + +| 方案 | 拓扑 | 结论 | +|------|------|------| +| **选定:CA 前置** | `waf → ca → gateway` | ✅ 验签在 CA 本地(零网络跳);core/dnfs2 调 8443 不变;WAF/前端不变;gateway 无需新增路径路由机制 | +| 否决 A:CA 后置双栈 | `waf → gateway → ca`(HTTP+pymsg 双栈) | gateway 需新增路径级路由表;CORS/Set-Cookie/错误映射下沉 CA;两服务改动面都大 | +| 否决 B:CA 后置纯 pymsg | `waf → gateway →(pymsg) ca` | gateway 保留 HTTP↔pymsg 翻译层与 Cookie 职责,认证面仍在入口进程内,瘦身不彻底;且每请求多一跳 pymsg 验签或引入吊销延迟的缓存 | +| 否决 C:验签缓存 | gateway 缓存验签结果 60s | logout/改密后 Token 在窗口内仍可用,吊销语义弱化(随拓扑选定自然消失) | + +### 2.3 信任模型(重要) + +1. **loopback 信任基线不变**:本架构 loopback 内部服务本就不互鉴权(db:8005 execute 无认证即先例)。gateway:8445 仅绑 `127.0.0.1`,与本基线一致。 +2. **身份头契约(strip-then-set)**:CA 在边缘剥离客户端伪造的 `X-Auth-User` / `X-Auth-Admin`(Beast fields 大小写不敏感,`erase` 后 `set`),验签通过才注入。模式 A 下 CA 的唯一上游是 WAF(loopback)。 +3. **gateway 存在性不变量**:显式 `Target-Url` 请求必须携带 `X-Auth-User`,否则 401(非验签,仅防线编程,防本地误直连/SSR​​F 兜底)。 +4. **模式 B 警示迁移**:CA 直连公网(无 WAF)时 XFF 可被伪造、限流可绕过——原 gateway README 的 A4 降级提示随限流一并迁入 CA README;根治方案 [libhttp_remote_addr_dev_doc.md](libhttp_remote_addr_dev_doc.md) 中的 `trust_xff`/`rate_limit_key()` 配套改在 CA 落地。 --- -## 三、实施阶段 +## 3. CA 模块设计 -### 阶段一:auth 服务搭建(核心剥离) +### 3.1 目录与构建(沿用 smtp3/captcha2 约定) -**目标**:将认证逻辑整体迁入独立 C++ 服务 - -**新建目录结构**: ``` -auth/ -├── src/ -│ └── main.cpp # 入口 + 路由注册 +ca/ +├── src/main.cpp # 入口 + HTTP 路由 + 认证逻辑 + 前置转发(迁移代码为主) ├── inc/ -│ ├── AuthService.hpp # 核心认证逻辑(PBKDF2、Token 签发/验签/吊销) -│ ├── RateLimiter.hpp # 速率限制器(从 gateway 迁移) -│ ├── CaptchaService.hpp # CAPTCHA 生成/验证 -│ ├── DbC.hpp # 复用 gateway 的 DbC(提取为公共库或复制) -│ ├── LogC.hpp # 复用 LogC -│ ├── Conf.hpp # 复用 ConfIni -│ ├── LocalTime.hpp # 复用 -│ └── ExpBase.hpp # 复用 -├── Makefile -├── _run.sh -├── _set.sh +│ ├── Conf.hpp # ConfIni(confs 拉取 + env 覆盖 + 完整性校验) +│ ├── DbC.hpp # 自 gateway 随迁(已基于 libmsg us/Msg.hpp;client 名 "gateway"→"ca") +│ ├── LogC.hpp # 异步日志(smtp3 长连接版) +│ ├── ExpBase.hpp # 异常基类 +│ └── LocalTime.hpp # 时间工具 +├── Makefile # 链接 us_http / us_msg / us_crypt / ssl / crypto / boost_json / curl +├── _set.sh / _run.sh └── README.md ``` -**迁移清单**(从 gateway/src/main.cpp 提取): +- 二进制 `ca.bin`;版本从 `1.0.0` 起;日志服务名 `ca` +- 依赖:libhttp(HttpServer/HttpClient/ws_proxy)、libmsg(MsgClient/RpcClient)、libcrypt(MyRSA/Base64)、OpenSSL(PBKDF2/RAND_bytes)、boost::json/program_options/property_tree、libcurl +- **不依赖 libsqlite3**:Users 表访问全部经 db:8005(现状即如此) -| 组件 | 源位置 | 目标文件 | -|------|--------|---------| -| PBKDF2Hasher | main.cpp L62-85 | AuthService.hpp | -| PasswordValidator | main.cpp L87-160 | AuthService.hpp | -| TokenInfo / generate_jti / verify_token / revoke_* | main.cpp L340-760 | AuthService.hpp | -| handleLogin | main.cpp L1346-1525 | main.cpp (auth) | -| handleRegister | main.cpp L1891-1981 | main.cpp (auth) | -| handleLogout | main.cpp L1527-1554 | main.cpp (auth) | -| handleWhoami | main.cpp L1556-1606 | main.cpp (auth) | -| handleAdminVerify | main.cpp L1608-1657 | main.cpp (auth) | -| handleRefreshToken | main.cpp L1672-1837 | main.cpp (auth) | -| handleSendEmailCode | main.cpp ~L1983-2080 | main.cpp (auth) | -| handleResetSendCode | main.cpp ~L2080-2170 | main.cpp (auth) | -| handleResetPassword | main.cpp ~L2170-2308 | main.cpp (auth) | -| handleCaptcha / handleCaptchaImage | main.cpp ~L1158,1845-1890 | CaptchaService.hpp | -| RateLimiter 实例(8 个) | main.cpp L417-427 | RateLimiter.hpp | -| CAPTCHA_STORE | main.cpp L438-499 | CaptchaService.hpp | -| JTI_DENYLIST / USER_VER_MAP | main.cpp L447-465 | AuthService.hpp | +### 3.2 配置(`confs/configs/ca.ini` 新增) -**新增配置**(`confs/configs/auth.ini`): ```ini [Server] address = 127.0.0.1 -port = 8444 +port = 8443 # 接管 gateway 现端口,WAF upstream 不变 -[Token] +[Upstream] +gateway = http://127.0.0.1:8445 # 业务请求唯一上游 + +[Token] # 自 gateway.ini 原样迁入 keep_time = 3600 refresh_grace_multiplier = 2 max_refresh = 10 -[Admin] +[Admin] # 原样迁入 users = 1776177662@qq.com;277665039@qq.com; -[Downstream] +[WsProxy] # 自 gateway.ini 迁入 +routes = /ws/aibox=ws://127.0.0.1:8765; + +[CORS] # 原样迁入(P2-3:预检反射由 libhttp 处理,白名单暂仍不生效) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[Downstream] # 原样迁入 db_host = 127.0.0.1 db_port = 8005 captcha_host = 127.0.0.1 @@ -139,235 +149,207 @@ smtp_host = 127.0.0.1 smtp_port = 8008 ``` -**对外接口**(pymsg TCP 协议 + HTTP 双栈): +环境变量覆盖(与 gateway 现机制一致):`LISTEN_ADDR`/`LISTEN_PORT`、`UPSTREAM_GATEWAY`、`DB_*`、`CAPTCHA_*`、`SMTP_*`、`CONFS_HOST`/`CONFS_PORT`。配置加载:confs `get` 拉取(retry_on_refused 阻塞)→ env 覆盖 → 完整性校验。 -| 路由 | 协议 | 说明 | +### 3.3 HTTP 路由表(对外契约与原 gateway 完全一致) + +| 路由 | 处理 | 说明 | |------|------|------| -| POST /login | HTTP | 登录(兼容前端直访) | -| POST /register | HTTP | 注册 | -| POST /logout | HTTP | 注销 | -| POST /refresh_token | HTTP | Token 刷新 | -| POST /whoami | HTTP | 身份查询 | -| POST /register/send_code | HTTP | 发送注册验证码 | -| POST /reset/send_code | HTTP | 发送重置验证码 | -| POST /reset_password | HTTP | 重置密码 | -| POST /captcha | HTTP | 生成验证码 | -| GET /captcha/image | HTTP | 验证码图片代理 | -| verify | pymsg | 内部验签(替代 /admin/verify,供 core/dnfs2/gateway 调用) | -| whoami | pymsg | 内部身份查询(供 dnfs2 调用) | +| POST `/login` | 本地 | 强制远端 CAPTCHA;dummy PBKDF2 防枚举;签发 Token + `q4_token` Cookie | +| POST `/register` | 本地 | 密码强度→邮箱格式→CAPTCHA→邮箱验证码(smtp fail-closed)→写 Users | +| POST `/register/send_code`、`/reset/send_code` | 本地 | IP+邮箱级限流、强制 CAPTCHA、smtp 60s 冷却 429 透传 | +| POST `/reset_password` | 本地 | 防枚举恒成功文案;改密后版本号递增全设备吊销 | +| POST `/logout` | 本地 | jti 吊销 + 清 Cookie | +| POST `/refresh_token` | 本地 | 宽限期刷新、旧 jti 吊销、max_refresh 上限 | +| POST `/whoami` | 本地 | 恒 200,`{login:false}` 不泄露细节;core/dnfs2 依赖此契约 | +| POST `/admin/verify` | 本地 | fail-closed 401;core 依赖此契约 | +| GET `/captcha/image` | 本地 | pymsg 调 captcha2,PNG + `X-Captcha-Token` + `no-store`,10/min/IP | +| WS `[WsProxy]` 路径 | 本地 | 握手验签 `is_admin=true` → 桥接后端(ws/wss),双向透传 | +| GET/POST `*`(通配) | **转发** | 见 §3.4 | -### 阶段二:gateway 瘦身 +### 3.4 前置转发层(通配路由 → gateway:8445) -**目标**:gateway 仅保留反向代理 + WebSocket 代理 +迁移 `handleProxy` 的转发骨架,认证语义前置: -**保留内容**: -- `handleProxy`(HTTP 反代 + Token 透传) -- WebSocket 代理路由注册 + `verifyWsAdminToken`(改为调 auth 的 pymsg verify 接口) -- `is_special_header` / `is_safe_target` / `get_cookie_value` -- 配置加载、信号处理、LogC +1. **路径安全**:`is_safe_target()` 校验(`..`/`%2e`/`%2f`/`%5c`/空字节 → 400),CA/gateway 双层各执行一次(防线叠加,gateway 侧保留原校验)。 +2. **公开 vs 受保护**:无 `Target-Url` 头 → 公开目标(Proxy.default 语义在 gateway),**剥离**客户端 `X-Auth-User`/`X-Auth-Admin` 后直接转发,不注入任何凭证。 +3. **受保护**:有 `Target-Url` → 提取 Token(`Authorization` 优先,回退 `q4_token` Cookie)→ `verify_token` 本地验签(RSA→过期→jti→版本号)→ 失败按现契约 400/401,**不转发**;成功则注入: + - `Authorization: Bearer `(Cookie 来源也统一补齐,dnfs2 等下游零改动) + - `X-Auth-User: `、`X-Auth-Admin: true`(仅管理员;供 gateway 不变量与下游未来免回调使用) +4. **头卫生**:剥离客户端自带的 `X-Auth-User`/`X-Auth-Admin`(strip-then-set);XFF 透传 WAF 值(缺省 `unknown`);`Host`/`connection`/`Accept-Encoding` 等逐跳头处理沿用现 `is_special_header` 集合 + `x-auth-user`/`x-auth-admin` 新增。 +5. **转发失败语义**:gateway:8445 不可达 → 502(与现 captcha/smtp fail-closed 风格一致),记 warning 日志。 +6. **响应**:状态码与响应头原样回传(现 `handleProxy` 行为),`Connection: close`。 -**删除内容**: -- 所有 auth handler(handleLogin ~ handleResetPassword) -- PBKDF2Hasher / PasswordValidator / TokenInfo / JTI_DENYLIST / USER_VER_MAP -- CAPTCHA 相关代码 -- 8 个 RateLimiter 实例(迁移到 auth,auth 路由不再经过 gateway 限流) -- RSA 密钥对生成(`_KEYGEN`,迁移到 auth) +### 3.5 迁移资产清单(gateway `main.cpp` → CA `main.cpp`) + +代码以**整体搬迁**为主,保持实现逐字不变(降低回归面),仅做两处适配:日志服务名 `gateway`→`ca`;DbC client 名同上。 + +| 资产 | 备注 | +|------|------| +| PBKDF2Hasher / PasswordValidator / 常数时间比较 | 含 SHA256 旧哈希迁移注释块(保持注释状态) | +| TokenInfo / verify_token / generate_jti / revoke_jti / revoke_all_user_tokens / jti 黑名单(5000 上限清理)/ 用户版本号表 | RSA 密钥对启动内存生成(重启即全吊销,语义不变) | +| 8 个 RateLimiter 实例 + 邮箱级双窗口限流 | 限流键仍取 XFF(模式 A 可信;模式 B 警示见 §2.3-4) | +| handleLogin ~ handleResetPassword 全部 handler | 错误文案/状态码契约逐字保留(黑盒断言依赖) | +| handleWhoami / handleAdminVerify / handleRefreshToken / handleLogout | 同上 | +| handleCaptchaImage / verify_captcha_remote / verify_email_code_remote | fail-closed 语义保留 | +| verifyWsAdminToken + ws_proxy 注册 | 桥接目标直接为后端(不再经 gateway) | +| handleProxy 转发骨架 / is_safe_target / is_special_header / get_cookie_value / extract_token_from_request | 按 §3.4 改造 | +| wrapperHttp / 信号处理 / 优雅关闭(8s+3s 超时)/ ExpBase / ConfIni / LocalTime / DbC / LogC | 基础设施随迁 | + +### 3.6 不变量自检(启动时) + +- `[Upstream] gateway` 非空且为 `http://127.0.0.1:*`(防误配到公网) +- 模式 B 检测:`Server.address` 非 loopback 时启动日志打印 A4 安全降级警告 + +--- + +## 4. gateway 瘦身设计(v0.9.0) + +### 4.1 删除清单(预计 -1700 行,2470 → ~750 行) + +- 全部认证 handler 与 Token/PBKDF2/RSA/jti/版本号/限流/CAPTCHA/邮箱验证码代码 +- `q4_token` Cookie 读写、`extract_token_from_request`、代理内验签与 Authorization 注入 +- `[Token]`/`[Admin]`/`[WsProxy]`/`[Database]` 配置读取;下游 captcha/smtp 配置(db 保留供 LogC) +- `/login` 等 9 条认证路由 + `/captcha/image` 路由 + WS 代理注册循环 + +### 4.2 保留清单 + +- `handleProxy`(去验签化)+ `is_safe_target` + `is_special_header` + `wrapperHttp` +- 存在性不变量:显式 `Target-Url` 且无 `X-Auth-User` → 401(§2.3-3) +- 公开目标规则:无 `Target-Url` → `Proxy.default`,不校验身份头 +- XFF 处理:透传 CA 值(缺省 `unknown`) +- 配置加载、信号处理、优雅关闭、LogC + +### 4.3 精简后 `confs/configs/gateway.ini` -**gateway.ini 精简**: ```ini [Server] address = 127.0.0.1 -port = 8443 +port = 8445 # 迁出 8443;仅 CA 可达 tls = off [Proxy] default = http://127.0.0.1:9200 -opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; +closed = http://127.0.0.1:8005;http://127.0.0.1:8007; +opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200; -[Auth] -host = 127.0.0.1 -port = 8444 - -[WsProxy] -routes = /ws/aibox=ws://127.0.0.1:8765; - -[CORS] -allowed_origins = https://qsont.me;https://www.qsont.me +[Downstream] # 仅供 LogC 日志落库 +db_host = 127.0.0.1 +db_port = 8005 ``` -**认证路由处理方案**(二选一): +### 4.4 启动自检 -- **方案 A(推荐):gateway 代理认证路由** - - gateway 将 `/login`、`/register` 等路径加入代理白名单,Target-Url 自动指向 auth 服务 - - 前端请求路径不变:`POST /login` → WAF → gateway → auth - - 优势:前端零改动,gateway 仍是唯一入口 - -- **方案 B:auth 直暴露** - - WAF 配置分流规则,认证路径直接转发到 auth:8444 - - 需改动 WAF 配置,增加路由复杂度 - -### 阶段三:下游服务适配 - -**目标**:core 和 dnfs2 的验签调用目标从 gateway 切换为 auth - -#### 3.1 core 适配 - -**当前**(`core/src/main/HttpTask.cpp` L155-204): -```cpp -// HTTP POST → gateway:8443/admin/verify -std::string url = "http://" + host + ":" + std::to_string(port) + "/admin/verify"; -pmc::net::HttpClient client; -client.set_header("Authorization: " + raw); -client.post_stream_sync(url, "", ...); -``` - -**改造后**: -```cpp -// pymsg → auth:8444 verify 路由(消除 HTTP 回调,统一内部协议) -pmc::net::MsgClient client(auth_host, auth_port, "core"); -json::value req = {{"_route", "verify"}, {"token", raw_token}}; -std::string resp = client.call(json::serialize(req), 5.0); -``` - -#### 3.2 dnfs2 适配 - -**当前**(`dnfs2/src/dnfs2.cpp` L446-495): -```cpp -// HTTP POST → gateway:8443/whoami -http.post_stream_sync(_cfg.gateway_url, "{}", ...); -``` - -**改造后**: -```cpp -// pymsg → auth:8444 whoami 路由 -RpcClient auth_client(_cfg.auth_host, _cfg.auth_port, "dnfs2", timeout); -json::value result = auth_client.call("whoami", {{"token", token}}); -``` - -**配置文件变更**(`confs/configs/dnfs.ini`): -```ini -[auth] # 新增,替代 [gateway] -host = 127.0.0.1 -port = 8444 -timeout = 5 -``` - -### 阶段四:启动编排与部署 - -**core/init.txt 更新**: -``` -bash /opt/micro_servers/db2/_run.sh -bash /opt/micro_servers/confs/_run.sh -bash /opt/micro_servers/auth/_run.sh # 新增,须在 gateway 之前启动 -bash /opt/micro_servers/ai_review/_run.sh -bash /opt/micro_servers/dnfs2/_run.sh -bash /opt/micro_servers/smtp/_run.sh -bash /opt/micro_servers/static/_run.sh -bash /opt/micro_servers/waf/_run.sh -bash /opt/micro_servers/gateway/_run.sh -bash /opt/micro_servers/captcha/_run.sh -bash /opt/micro_servers/aibox/_run.sh -bash /opt/micro_servers/redirect80/_run.sh -``` - -**docker-compose.yml 新增 auth 服务**: -```yaml -auth: - build: - context: . - dockerfile: auth/Dockerfile - container_name: micro-auth - ports: - - "127.0.0.1:8444:8444" - environment: - - LISTEN_ADDR=0.0.0.0 - - LISTEN_PORT=8444 - - DB_HOST=db - - DB_PORT=8005 - depends_on: - db: - condition: service_healthy -``` +- `Server.address` 必须为 loopback(`127.0.0.1`/`::1`):gateway 不再验签后,绑定公网等于裸奔,**非 loopback 直接拒绝启动**并打印致命错误。 --- -## 四、接口契约设计 +## 5. 接口契约 -### 4.1 auth 内部 pymsg 接口(供其他微服务调用) +### 5.1 对外 HTTP(不变量) -#### verify 路由(替代 /admin/verify) -```json -// 请求 -{"_route": "verify", "token": "base64payload_signature", "require_admin": true} +`https:///login`、`/register`、`/whoami` 等全部 URL、请求/响应 JSON 结构、状态码(400/401/403/429/500/502)、`q4_token` Cookie 属性、`X-Captcha-Token` 头——**逐字不变**。前端、WAF、core、dnfs2 零改动的依据即此。 -// 响应(成功) -{"success": true, "user": "admin@example.com", "is_admin": true} +### 5.2 内部头契约(CA ↔ gateway ↔ 下游) -// 响应(失败) -{"success": false, "error": "token expired"} -``` +| 头 | 方向 | 语义 | +|----|------|------| +| `X-Auth-User` | CA→gateway→下游 | 已验签身份;CA strip-then-set;gateway 对显式 Target-Url 要求其存在 | +| `X-Auth-Admin` | CA→gateway→下游 | `true` 表示管理员;供下游免回调豁免(可选消费) | +| `Authorization` | CA→gateway→下游 | Cookie 来源统一补齐为 Bearer;下游解析不变 | +| `X-Forwarded-For` | WAF→CA→gateway→下游 | 逐层透传,缺省 `unknown` | +| `Target-Url` | 客户端→CA→gateway | 路由策略头,语义不变 | -#### whoami 路由(替代 /whoami) -```json -// 请求 -{"_route": "whoami", "token": "base64payload_signature"} +### 5.3 Token 格式(不变) -// 响应(已登录) -{"success": true, "login": true, "user": "user@example.com", "is_admin": false} +`Base64(payload)_RSA-Sign(payload)`;payload 字段(user/jti/ver/iat/created_at/keep_time/is_admin/refresh_count/max_refresh)不变。切换瞬间旧 Token 全部失效(CA 新密钥),用户重新登录——一次性事件,cutover 通告中说明。 -// 响应(未登录/Token 无效) -{"success": true, "login": false} -``` +### 5.4 下游零改动验证点 -### 4.2 Token 格式不变 - -保持现有 `Base64(payload)_RSA-Sign(payload)` 格式,payload 字段不变: -```json -{ - "user": "email@example.com", - "jti": "random_hex_32", - "ver": 1, - "iat": "2026-08-08 12:00:00", - "created_at": "2026-08-08 12:00:00", - "keep_time": 3600, - "is_admin": true, - "refresh_count": 0, - "max_refresh": 10 -} -``` +| 消费方 | 现状调用 | 切换后 | +|--------|----------|--------| +| core `verify_admin_token` | `http://127.0.0.1:8443/admin/verify` | 同 URL 由 CA 服务,契约不变 | +| dnfs2 Token 验签 | `http://127.0.0.1:8443/whoami` | 同上 | +| static `/api/check_user` | 直连 db 查 Users(账号旁路) | 不受影响;遗留项另行收口 | --- -## 五、风险评估与缓解 +## 6. 上线步骤(cutover 与回滚) -| 风险 | 影响 | 缓解措施 | -|------|------|---------| -| auth 服务不可用导致全系统鉴权失败 | 高 | auth 的 pymsg 客户端启用 `retry_on_refused`;gateway 对 auth 路由的代理失败返回 503(而非静默放行) | -| 前端请求路径变化 | 中 | 采用方案 A(gateway 代理认证路由),前端零改动 | -| Token 验签增加一跳网络延迟 | 低 | pymsg TCP 本机通信延迟 <1ms;gateway 代理层可增加 Token 缓存(TTL 60s) | -| RSA 密钥迁移 | 中 | auth 启动时自生成密钥对(与当前 gateway 行为一致);过渡期可共享密钥文件 | -| 过渡期双系统并存 | 中 | 分阶段切换:先 auth 上线 → gateway 认证路由代理到 auth → 删除 gateway 内置 auth 代码 | -| Token 黑名单持久化需求 | 低 | 初期保持纯内存方案(与现状一致);后续可选 Redis 持久化 | +| 步骤 | 内容 | +|------|------| +| 1 | CA 开发完成,`LISTEN_PORT=18443` 并行实例联调(不影响现网 8443) | +| 2 | gateway 瘦身版(v0.9.0)构建,`gateway.ini` 改 8445 | +| 3 | cutover(秒级窗口):停 gateway:8443 → 启 CA:8443 → 启 gateway:8445 → 黑盒全量验证 | +| 4 | `core/init.txt` 增加 `ca/_run.sh`(gateway 之前);README/INDEX 文档同步 | +| 5 | 观察 24h:CA/gateway 日志、db Logs 表 4xx/5xx 基线对比 | + +**回滚**:停 CA → 旧 gateway 二进制 + 旧配置回 8443 → 重启。Token 全部重签(用户重新登录),无数据迁移。 --- -## 六、时间估算 +## 7. 测试计划 -| 阶段 | 工作量 | 说明 | -|------|--------|------| -| 阶段一:auth 服务搭建 | 2-3 天 | 代码迁移 + 新配置 + 测试 | -| 阶段二:gateway 瘦身 | 1 天 | 删除代码 + 代理路由配置 + 回归测试 | -| 阶段三:下游适配 | 1 天 | core/dnfs2 验签调用改造 + 集成测试 | -| 阶段四:部署编排 | 0.5 天 | init.txt + docker-compose + 端到端测试 | -| **合计** | **4.5-5.5 天** | 含测试缓冲 | +### 7.1 验收门(零改动复用) + +- `tests/blackbox/test_gateway.py` 34 断言**原样**对 8443 执行:认证路径打到 CA、代理路径经 CA→gateway,外部契约不变则全绿(当前基线 32 passed / 2 env-skipped) +- 修复期两个红测试(A3 缺字段 400)已在 v0.8.0 转绿,迁移后必须保持 + +### 7.2 新增探针(test_ca.py,黑盒补充) + +| 探针 | 预期 | +|------|------| +| 客户端伪造 `X-Auth-User: admin@x` + 显式 Target-Url(无 Token) | CA 剥离后 401,不到下游 | +| 伪造 `X-Auth-User` + 有效非管理员 Token 访问 WS 路由 | 403(以验签结果为准,不以头为准) | +| 直连 gateway:8445,显式 Target-Url、无 `X-Auth-User` | 401(存在性不变量) | +| 经 CA 全链路(8443)带 Token 访问 dnfs2 | 下游收到 `X-Auth-User` 与 `Authorization` | +| CA 存活、gateway:8445 停止 | 业务路径 502(不静默放行),认证路径正常 | +| gateway 存活、CA 停止 | WAF 502(入口不可达,符合预期 fail-closed) | +| 公开目标(无 Target-Url)带伪造身份头 | 下游收不到 `X-Auth-User`/`Authorization` | + +### 7.3 集成回归 + +- core HTTP 管理接口(`/admin/verify` 链路)实测;dnfs2 笔记 CRUD(`/whoami` 链路 + AI 审核)实测 +- WS 代理端到端:管理员 Token 升级 `/ws/aibox` 双向帧;非管理员 403 +- 限流全谱在 CA 复测(随机 XFF 技巧沿用);cutover/回滚演练各一次 + +### 7.4 测试资产演进(后续) + +- `test_gateway.py` 按进程边界拆分为 `test_ca.py`(A/B/C/E 段)+ 精简 `test_gateway.py`(D 段代理矩阵 + 8445 直连探针),README/INDEX 同步 +- 认证 happy path 仍依赖 captcha2/smtp 测试钩子(既有遗留,CA 落地后可仅在 CA 层加 `[Security] captcha_required` 开关,单点解锁) --- -## 七、验收标准 +## 8. 风险与缓解 -1. auth 服务独立启动,通过 pymsg 和 HTTP 双栈提供全部认证接口 -2. gateway 代码量从 ~2500 行降至 ~800 行,仅含代理逻辑 -3. 前端所有认证操作(登录/注册/注销/刷新/重置密码)路径不变、行为不变 -4. core/dnfs2 通过 pymsg 调用 auth 验签,延迟 ≤ 5ms(本机) -5. auth 服务重启不影响 gateway 代理转发(已认证请求在 Token 过期前仍有效) -6. 所有现有安全特性(PBKDF2、RSA 签名、常数时间比较、jti 黑名单、版本号吊销)完整保留 +| 风险 | 等级 | 缓解 | +|------|------|------| +| CA 不可用 → 全站入口中断 | 高 | CA 职责内聚后可用性等同于现 gateway;core 守护 + 黑盒监控;回滚方案秒级 | +| 迁移遗漏导致认证行为漂移 | 中 | 代码逐字搬迁 + 34 断言验收门 + §7.2 探针矩阵 | +| 身份头伪造/泄露 | 中 | strip-then-set + gateway 存在性不变量 + 公开目标不注入 + 直连探针回归 | +| gateway:8445 被公网暴露 | 高 | 启动强制 loopback 自检(§4.4);部署审计 checklist 增加端口扫描项 | +| cutover 窗口 Token 全失效 | 低 | 预期内(RSA 换钥),通告用户重新登录 | +| 双重转发延迟 | 低 | 本机 HTTP 转发 <1ms;CA→gateway 短连接可后续优化为 keep-alive | +| 模式 B 限流绕过(A4 迁移) | 中 | CA README 降级警示;根治按 libhttp_remote_addr_dev_doc(配套改落 CA) | + +--- + +## 9. 工作量估算 + +| 部分 | 规模 | 估时 | +|------|------|------| +| CA 脚手架(目录/Makefile/_set.sh/_run.sh/LogC 等) | 模板复制 + 适配 | 0.5 天 | +| 认证资产搬迁 + 前置转发层改造 | ~1700 行搬迁 + ~150 行改造 | 1.5 天 | +| gateway 瘦身 + 启动自检 | -1700 行 + ~20 行 | 0.5 天 | +| 配置/init.txt/文档(README×2、INDEX、confs) | — | 0.5 天 | +| 黑盒验收 + §7.2 探针 + 集成回归 + cutover 演练 | — | 1 天 | +| **合计** | | **4 天**(含缓冲) | + +--- + +## 10. 与既有文档的关系 + +- 本文档**替代** 2026-08-08 初版计划书中的双栈拓扑与阶段划分(初版的职责盘点/迁移清单仍然有效,已并入 §1.2/§3.5) +- [libhttp_remote_addr_dev_doc.md](libhttp_remote_addr_dev_doc.md)(A4 根治):实施时 `trust_xff`/`rate_limit_key()` 配套由 gateway 改为落 CA;`is_special_header` 两侧同步加 `x-remote-addr` +- [gateway_SECURITY_AUDIT_20260809.md](../security/gateway_SECURITY_AUDIT_20260809.md):A2(白名单 403)断言、P2-3(CORS 白名单)随转发层/配置迁入 CA;P1-7(CSRF Token)在 CA 层评估 +- gateway README / 项目 README 的架构图、端口表、请求链路在上线步骤第 4 步同步更新 diff --git a/gateway/Makefile b/gateway/Makefile index 8b4415d..2bad387 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -1,10 +1,10 @@ -VERSION := 0.8.0 +VERSION := 0.9.0 APP_NAME := gateway INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ -I ../libmsg/inc MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg -LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) +LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build $(shell mkdir -p $(BUILD_DIR)) @@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR)) all: $(BUILD_DIR)/$(APP_NAME).out $(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ -$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp +$(BUILD_DIR)/main.o: src/main.cpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ clear: @rm -rvf build/ diff --git a/gateway/README.md b/gateway/README.md index 1f58a3f..0cd9673 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -2,301 +2,101 @@ UniServe Framework 的应用层网关,C++20 实现。 -在引入 [waf](../waf/) 模块后,gateway 退化为内部 HTTP 服务:TLS 由 WAF 终止,gateway 专注做账号鉴权与反向代理。通过 `Server.tls` 配置可一键切换独立部署(HTTPS)或与 WAF 协作(HTTP)两种模式。 +**v0.9.0 起为纯转发服务**:认证职责(Token 签发/验签、登录/注册/重置、限流、WS 代理鉴权)已独立为 [ca](../ca/) 模块。请求链路变为 `waf:443 → ca:8443 → gateway:8445 → 内部服务`,gateway 仅监听 loopback,不再验签。 ## 职责 -- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 -- **HTTP 反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 -- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务,支持文本/二进制帧双向转发。 -- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 -- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 -- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。 -- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(任意来源均被放行);`CORS.allowed_origins` 白名单已读取但尚未用于预检响应(见审计 P2-3)。 +- **HTTP 反向代理**:`GET/POST *` 通配路由,按 `Target-Url` 头转发到 `Proxy.opened` 白名单内的内部服务;缺省走 `Proxy.default`(前端静态资源)。 +- **存在性不变量(非验签)**:显式携带 `Target-Url` 的受保护请求必须带有 ca 注入的 `X-Auth-User` 头,缺失一律 401——用于拦截"未经 ca 直连 8445"的误配/调试流量,不是安全边界(安全边界是 loopback 监听 + ca 的 strip-then-set)。 +- **身份头透传**:`X-Auth-User` / `X-Auth-Admin` / `Authorization` 不在 `is_special_header` 剥离列表中,原样转发给下游(dnfs2/core 等零改动)。 +- **XFF 覆写**:剥离客户端伪造的 `X-Forwarded-For`/`X-Real-IP`/`Forwarded`,以收到的 XFF(ca 链路注入)为准重新设置,下游直接信任。 -## 安全特性 +## 信任模型 -| 特性 | 实现 | -|------|------| -| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16 字节随机盐,32 字节哈希 | -| 密码强度校验 | 至少 8 字符;大写/小写/数字/特殊字符中至少 3 类 | -| 邮箱格式校验 | 正则匹配 `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` | -| 旧哈希自动迁移 | 登录时若发现 SHA256 旧哈希,验证通过后自动迁移为 PBKDF2(⚠️ 当前源码中该迁移逻辑已被注释禁用,见 `main.cpp` L1102-1109,未生效) | -| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期校验(默认 1 小时) | -| 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack | -| CAPTCHA | 一次性、5 分钟过期,登录与注册均可选启用 | -| 速率限制 | 内存计数器,自动清理超过 1 万条的老条目 | -| TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | -| 下游服务容错 | pymsg 客户端 `retry_on_refused` 机制:db 等强依赖服务未启动时自动阻塞重试,避免启动顺序问题;日志客户端 fail-safe 静默丢弃 | +``` +waf:443 ──HTTP──▶ ca:8443 ──(注入 X-Auth-User)──▶ gateway:8445 ──▶ static:9200 / dnfs2:8006 / core:5200 + ▲ 安全边界 ▲ 仅 loopback +``` -## pymsg 内部通信协议 - -gateway 通过项目内的 [pymsg](../pymsg/)(长度前缀帧 TCP 协议)与内部微服务通信,C++ 实现在 `inc/Msg.hpp`,与 Python 端字节级兼容。 - -**线协议**:`[4字节大端uint32长度][UTF-8载荷]`,最大 64 MiB。 - -**JSON RPC 约定**:请求形如 `{"_route": "<路由名>", ...}`,响应形如 `{"success": true, "data": ...}` 或 `{"success": false, "error": "..."}`。 - -**下游服务调用方式**: - -| 下游 | 默认端口 | `_route` | 调用位置 | 重试策略 | -|------|---------|----------|---------|---------| -| db | 8005 | `execute` | `DbC::call_msg()`(所有数据库 CRUD) | `retry_on_refused=true`,db 未就绪时阻塞等待 | -| db | 8005 | `execute` | `LogC::_send()`(日志异步批量推送) | `retry_on_refused=false`,fail-safe 静默丢弃 | -| captcha | 8009 | `verify` / `image` | `verify_captcha_remote()`、`/captcha/image` 代理 | 默认不重试,连接失败 fail-closed 返回 502/false | -| smtp | 8008 | `verify_email` / `send_email` | `verify_email_code_remote()`、`/register/send_code` | 默认不重试,连接失败 fail-closed 返回 502/false | -| confs | 8010 | `get` | 启动时拉取 `gateway.ini` 配置 | `retry_on_refused=true`,confs 未就绪时阻塞等待 | - -**retry_on_refused 机制**(`MsgClient::call()`,对应 Python 端 `MsgClient.call(retry_on_refused=True)`): -- 仅对 `ECONNREFUSED`(目标端口无服务监听)重试,超时、连接重置、业务错误立即抛出 -- 固定 1 秒间隔重试;首次失败立即打印 stderr 提示,之后每 5 秒打印一次等待日志(节制日志,避免刷屏) -- 用于解决微服务启动顺序不确定问题:gateway 启动时 db 未就绪,首次 db 调用自动阻塞等待直到 db 上线 +- gateway 不持有 RSA 密钥、不验签;身份完全信任 ca 注入的头。 +- 因此 **启动强制 loopback 自检**:`Server.address` 非 `127.0.0.1`/`::1`/`localhost` 时拒绝启动(防止误配导致外部直连伪造身份)。 +- `db:8005` 的 `execute` 路由本就无认证,loopback 信任模型与既有基线一致。 ## 接口 -### `POST /captcha` - -生成验证码。返回: - -```json -{ - "token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", - "text": "Ab3xy" -} -``` - -### `POST /login` - -请求体: - -```json -{ - "username": "user@example.com", - "password": "P@ssw0rd", - "captcha_token": "a1b2c3d4...", // 可选 - "captcha_answer": "Ab3xy" // 可选 -} -``` - -成功响应: - -```json -{ - "success": true, - "data": { "token": "_" } -} -``` - -失败响应: - -```json -{ "success": false, "message": "Invalid Username or Password" } -``` - -错误码:`400`(参数/CAPTCHA 错误)、`401`(账号/密码错或 Token 过期)、`429`(速率超限)。 - -### `POST /register` - -请求体在 `/login` 基础上增加 `email_code`(必填,6 位数字邮箱验证码): - -```json -{ "username": "user@example.com", "password": "...", "email_code": "123456" } -``` - -校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。 - -错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。 - -### `POST /register/send_code` - -向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 - -**请求体**(CAPTCHA 必填,防邮件轰炸): - -```json -{ - "email": "user@example.com", - "captcha_token": "a1b2c3d4...", - "captcha_answer": "Ab3xy" -} -``` - -**防护层次**: -1. IP 限流:5 次/10 分钟(`X-Forwarded-For`) -2. CAPTCHA 强制校验:经 captcha:8009 pymsg 校验,Token 一次性消费 -3. 邮箱级双窗口限流:3 次/小时 + 10 次/天 -4. smtp 同邮箱 60 秒发送冷却(冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却) -5. smtp 邮箱级长期限流兜底 + 全局熔断(默认 100 封/小时,`SMTP_HOURLY_LIMIT` 可配) - -错误码:`400`(邮箱格式错误/CAPTCHA 缺失或错误)、`429`(IP 速率超限/邮箱级限流超限/同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 - ### `GET/POST *`(反向代理) -携带 header: - ``` -Authorization: Bearer _ -Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default +X-Auth-User: user@example.com // 受保护请求必需(由 ca 注入) +Target-Url: http://127.0.0.1:8006 // 可选,缺省取 Proxy.default ``` -- `Target-Url` 缺省时走 `Proxy.default`(不需 Token)。 -- 自定义 `Target-Url` 必须在 `Proxy.opened` 白名单中,否则返回 403。 -- 验签通过且未过期(默认 1 小时)才转发,否则返回 401。 -- `Proxy.closed`(如 db:8005、ai_review:8007)不在 `opened` 中,外部无法直访。 +- `Target-Url` 缺省时走 `Proxy.default`(公开,不检查 `X-Auth-User`)。 +- 自定义 `Target-Url` 必须在 `Proxy.opened` 白名单中,否则 403。 +- 显式 `Target-Url` 且无 `X-Auth-User` → 401。 +- `Proxy.closed`(如 db:8005、ai_review:8007)不在 `opened` 中,无法经 gateway 直访。 -### `OPTIONS *` - -返回 CORS 预检响应:`Access-Control-Allow-Origin` 反射请求 Origin 头(等价于允许任意来源,见审计 P2-3)、`Access-Control-Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。 - -### WebSocket 代理(`[WsProxy]` 配置的路径) - -握手阶段鉴权规则: - -1. 从 `Authorization: Bearer ` 头读取 Token;若缺失则回退到 `Cookie: q4_token=`。 -2. RSA 验签 + 过期校验(与 HTTP 代理同样的 Token 机制)。 -3. Token payload 中必须 `is_admin=true`(即登录账号在 `Admin.users` 白名单内)。 -4. 鉴权失败 → 返回 HTTP 403 Forbidden,连接被拒绝。 -5. 鉴权通过 → 升级为 WebSocket 并透明桥接到配置的后端 ws:// 或 wss:// 地址,双向文本/二进制帧原样转发。 - -客户端使用方式与普通 WebSocket 一致,只需携带有效管理员 Token: - -```javascript -// 浏览器(同源会自动带 q4_token Cookie) -const ws = new WebSocket("wss://qsont.me/ws/aibox"); - -// 或显式带 Authorization(构造子协议或连接后首条消息均可,这里由网关在 Upgrade 头校验) -``` - -注意:WS 代理路由必须精确匹配(与 `ws()` 一致,非通配),且优先级高于 HTTP 通配 `*` 路由。 - -## 数据库 - -用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表): - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | INTEGER PK AUTOINCREMENT | | -| username | TEXT UNIQUE NOT NULL | 登录账号(邮箱格式) | -| password_hash | TEXT NOT NULL | `pbkdf2:100000::` | -| create_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | -| last_login | DATETIME | 每次登录更新 | -| is_active | BOOLEAN DEFAULT 1 | | - -gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留),所有读写经 `DbC::call_msg` 调 db 的 `execute` 路由。无默认管理员账号——首位注册者即为管理员。 +错误响应统一脱敏:`HttpException` 仅返回纯消息(内部前缀留服务端日志),其余异常一律 `500 {"success": false, "message": "Internal Server Error"}`。 ## 配置 -gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/static)保持一致: +两层加载:confs 拉取(pymsg `get`,`retry_on_refused=true`)→ 环境变量覆盖。 -1. **confs 统一配置服务**:启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 -2. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 -3. **完整性校验**:必需配置项缺失则启动失败。 +配置文件:[confs/configs/gateway.ini](../confs/configs/gateway.ini)。 -配置文件统一存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录下不再保留本地配置文件。 - -配置项说明: -- `Server.address`/`port`/`tls`:监听地址、端口、TLS 开关;`address`/`port` 可被 `LISTEN_ADDR`/`LISTEN_PORT` 环境变量覆盖。 -- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token)。 -- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 -- `Proxy.closed`:仅文档记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 -- `Token.*`:Token 有效期(`keep_time`)、刷新宽限期倍数(`refresh_grace_multiplier`)、最大刷新次数(`max_refresh`)。 -- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 -- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。⚠️ 当前已读取但**尚未用于预检响应**——OPTIONS 预检由 libhttp 反射请求 Origin,白名单暂不生效(见审计 P2-3)。 -- `WsProxy.routes`:WebSocket 代理路由,格式 `/path=ws://host:port/target`,分号分隔。 -- `Downstream.*`:下游 pymsg 服务地址(db/captcha/smtp),可被 `DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 环境变量覆盖。 - -## 部署模式 - -### 模式 A:与 WAF 协作(推荐) - -``` -客户端 ──HTTPS──▶ waf:443 (终止 TLS + coraza 规则) ──HTTP──▶ gateway:8443 -``` - -```ini -address = 127.0.0.1 -port = 8443 -tls = off -``` - -证书放在 `waf/certs/`。RateLimiter 从 WAF 注入的 `X-Forwarded-For` 取真实客户端 IP。 - -### 模式 B:独立部署 - -``` -客户端 ──HTTPS──▶ gateway:443 (终止 TLS + 鉴权 + 反代) -``` - -```ini -address = 0.0.0.0 -port = 443 -tls = on -``` - -证书 `full_chain.crt` / `server.key` 放在 gateway 目录。RateLimiter 取不到 `X-Forwarded-For`,所有请求记为 `"direct"`。 +- `Server.address`/`port`/`tls`:默认 `127.0.0.1:8445`(v0.9.0 起 8443 归 ca);env `LISTEN_ADDR`/`LISTEN_PORT`。 +- `Proxy.default` / `Proxy.opened` / `Proxy.closed`:转发目标与白名单(分号分隔)。 +- `Downstream.db_host`/`db_port`:日志推送端点(env `DB_HOST`/`DB_PORT`)。 ## 编译与运行 -依赖系统库(由根目录 [_setup.deb.sh](../_setup.deb.sh) 或 [_setup.rhel.sh](../_setup.rhel.sh) 安装): - -- `libboost_json`、`libboost_program_options` -- `libcurl` -- `libssl` / `libcrypto`(OpenSSL,PBKDF2 + RSA + RAND_bytes) -- `libsqlite3` -- 项目内库:`libus_http`、`libus_crypt`(由 [libhttp](../libhttp/)、[libcrypt](../libcrypt/) 编译安装) - ```bash bash _set.sh # make clean && make bash _run.sh # 从 confs 拉取配置启动 ``` -无需传参,配置统一由 confs 管理。命令行参数:`-h/--help`、`-v/--version`。 +命令行参数:`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `gateway`)。 ## 目录结构 ``` gateway/ ├── src/ -│ └── main.cpp # 全部实现:配置加载(confs → 环境变量覆盖)、PBKDF2、RateLimiter、路由/handler +│ └── main.cpp # 全部实现:配置加载、存在性不变量、反向代理(约 560 行) ├── inc/ -│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused)、MsgException 族 -│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2(地址可配置,retry_on_refused=true) -│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe) -│ ├── Conf.hpp # ConfIni:boost::property_tree ini 解析,支持 fromString/env/env_int +│ ├── LogC.hpp # 统一日志客户端(异步批量,fail-safe) +│ ├── Conf.hpp # ConfIni:ini 解析 + env 覆盖 │ ├── ExpBase.hpp # 异常基类(带 source_location) -│ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) +│ ├── DbC.hpp # (保留备用)db 服务客户端 +│ └── LocalTime.hpp # (保留备用)本地时间工具 ├── Makefile ├── _set.sh / _run.sh └── README.md ``` -配置文件统一在 [confs/configs/gateway.ini](../confs/configs/gateway.ini),gateway 目录无本地配置文件。 - ## 版本 -`0.8.0`(见 [src/main.cpp:35](src/main.cpp#L35))。启动时打印: +`0.9.0`(见 `src/main.cpp`)。启动时打印: ``` -Gateway 0.8.0 started +Gateway 0.9.0 started ``` 近期变更: +- `0.9.0`:**认证独立化瘦身**——认证资产(Token 签发/验签、`/login` `/register` `/logout` `/refresh_token` `/whoami` `/admin/verify` `/captcha/image` `/reset_*` 全部 handler、限流、WS 代理鉴权、PBKDF2/RSA)整体迁至新模块 [ca](../ca/)(v1.0.0,逐字保留实现);gateway 退居 `127.0.0.1:8445` 纯转发(8443 由 ca 接管,WAF upstream 不变);新增启动 loopback 强制自检与 `X-Auth-User` 存在性不变量(显式 `Target-Url` 无该头 → 401);移除对 libcrypt/OpenSSL/sqlite3 的运行时依赖(编译仍链接 libus_crypt 库)。黑盒 tests/blackbox/test_gateway.py 34 断言对 8443(现为 ca)原样全绿。 - `0.8.0`:**P0 安全问题全部修复**(安全评级 B+ → A-)——HttpException 错误消息脱敏(4 处内部步骤消息替换为用户友好文案,修正注册失败状态码 400→500);**进程退出 bug 修复**——信号处理改为 async-signal-safe(仅设 `volatile sig_atomic_t` 标志 + `write()` 输出)、新增 SIGPIPE 忽略、stop() 添加 8 秒超时保护 + 日志 3 秒超时(保证 11 秒内必定退出)、服务器事件循环改在独立线程运行;Token 双轨吊销机制(jti 黑名单 + 用户版本号)、错误信息脱敏(不返回源码路径/C++ 类型名)、JTI 黑名单内存有界保护(5000 条阈值 + 过期清理)、反向代理响应大小限制(libhttp 默认 10MB)、集中式异步日志客户端 LogC、WebSocket 代理(管理员权限校验)、多层速率限制(IP 级 + 邮箱级)、PBKDF2-HMAC-SHA256 密码哈希(10 万轮)、RSA Token 签名验签、HttpOnly+Secure+SameSite Cookie、密码强度校验。 -- `0.7.0`:配置体系完全迁移到 confs 统一配置服务——配置文件统一存放于 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段),删除本地 example.ini,移除 `-i/--input` 命令行参数(不再支持本地 ini 文件回退,与 static 模块一致);启动时从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`);`ConfIni` 增加 `fromString()`/`env()`/`env_int()`;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验;`DbC` 增加 `init(host, port)`,main.cpp 所有硬编码 captcha/smtp/db 地址改为配置变量,`logs().set_endpoint()` 启动时初始化;`--version/-v` 选项补全实现;版本升至 0.7.0。 +- `0.7.0`:配置体系完全迁移到 confs 统一配置服务——配置文件统一存放于 `confs/configs/gateway.ini`,删除本地 example.ini,移除 `-i/--input` 命令行参数;启动时从 confs 通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`);`ConfIni` 增加 `fromString()`/`env()`/`env_int()`;环境变量覆盖;配置完整性校验;`--version/-v` 选项补全实现。 - `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制——新增 `MsgConnectionRefusedException`;`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数;`DbC::call_msg()` 启用重试等待 db 就绪;`LogC::_send()` 显式不重试(fail-safe)。 -- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 -- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 -- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 -- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 -- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 +- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段;握手阶段校验 Token 中 `is_admin=true`,通过后双向透明桥接文本/二进制帧到后端。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。(v0.9.0 起迁至 ca) +- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验,新增邮箱级双窗口限流;smtp 层同步增加邮箱级长期限流兜底 + 全局熔断。(v0.9.0 起迁至 ca) +- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案。 +- `0.5.2`:限流错误码统一为 429;登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;pymsg 下游调用超时常量化。 +- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵)。 ## 依赖 - C++20(`g++`) - Boost:`boost::json`、`boost::program_options`、`boost::property_tree` -- OpenSSL:PBKDF2-HMAC-SHA256、RSA 签名/验签、`RAND_bytes` - libcurl(反向代理出站请求) -- libsqlite3(用户表存储) -- 项目库:[libhttp](../libhttp/)(HttpServer/HttpClient)、[libcrypt](../libcrypt/)(MyRSA/Base64/SHA256)、[libsqlite3](../libsqlite3/) +- 项目库:[libhttp](../libhttp/)(HttpServer/HttpClient)、[libmsg](../libmsg/) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 203690e..ffb4b31 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -1,30 +1,19 @@ #include #include -#include -#include -#include -#include #include #include #include #include #include #include -#include -#include #include #include #include #include #include #include -#include -#include -#include #include "ExpBase.hpp" -#include "LocalTime.hpp" #include "Conf.hpp" -#include "DbC.hpp" #include "LogC.hpp" using namespace pmc::net; @@ -32,7 +21,11 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.8.0"; +/* v0.9.0:认证独立化瘦身(waf → ca → gateway 前置拓扑) + * - 认证资产(Token 签发/验签、登录/注册/重置、WS 代理鉴权)已全部迁至 ca 模块 + * - gateway 退化为纯转发服务:仅 loopback 监听(默认 8445),不验签 + * - 身份契约:显式 Target-Url 请求必须携带 ca 注入的 X-Auth-User 头(存在性不变量) */ +constexpr const char *_VER = "0.9.0"; ////////////////////////////////// #include @@ -57,189 +50,6 @@ std::vector split(const std::string& s, char delim) { } ////////////////////////////////// -// PBKDF2-HMAC-SHA256 password hashing -////////////////////////////////// -class PBKDF2Hasher { -public: - static const int ITERATIONS = 100000; - static const int SALT_LEN = 16; - static const int HASH_LEN = 32; - - static std::string hash_password(const std::string& password) { - unsigned char salt[SALT_LEN]; - RAND_bytes(salt, SALT_LEN); - - unsigned char hash[HASH_LEN]; - PKCS5_PBKDF2_HMAC( - password.c_str(), password.size(), - salt, SALT_LEN, - ITERATIONS, - EVP_sha256(), - HASH_LEN, hash - ); - - // Format: pbkdf2:100000:: - std::string salt_hex = bytes_to_hex(salt, SALT_LEN); - std::string hash_hex = bytes_to_hex(hash, HASH_LEN); - return "pbkdf2:" + std::to_string(ITERATIONS) + ":" + salt_hex + ":" + hash_hex; - } - - static bool verify_password(const std::string& password, const std::string& stored) { - // Parse stored hash - if (stored.substr(0, 7) != "pbkdf2:") { - return false; - } - - auto parts = split(stored, ':'); - if (parts.size() != 4) return false; - - int iterations = std::stoi(parts[1]); - auto salt = hex_to_bytes(parts[2]); - auto expected_hash = hex_to_bytes(parts[3]); - - unsigned char hash[HASH_LEN]; - PKCS5_PBKDF2_HMAC( - password.c_str(), password.size(), - salt.data(), salt.size(), - iterations, - EVP_sha256(), - HASH_LEN, hash - ); - - // Constant-time comparison - if (expected_hash.size() != (size_t)HASH_LEN) return false; - unsigned char diff = 0; - for (int i = 0; i < HASH_LEN; i++) { - diff |= hash[i] ^ expected_hash[i]; - } - return diff == 0; - } - -private: - static std::string bytes_to_hex(const unsigned char* data, size_t len) { - static const char hex[] = "0123456789abcdef"; - std::string result; - result.reserve(len * 2); - for (size_t i = 0; i < len; i++) { - result.push_back(hex[data[i] >> 4]); - result.push_back(hex[data[i] & 0x0F]); - } - return result; - } - - static std::vector hex_to_bytes(const std::string& hex_str) { - std::vector bytes; - bytes.reserve(hex_str.size() / 2); - for (size_t i = 0; i < hex_str.size(); i += 2) { - auto hi = hex_char_to_nibble(hex_str[i]); - auto lo = hex_char_to_nibble(hex_str[i + 1]); - bytes.push_back((hi << 4) | lo); - } - return bytes; - } - - static unsigned char hex_char_to_nibble(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); - if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); - return 0; - } -}; - -////////////////////////////////// -// Password strength validator -////////////////////////////////// -class PasswordValidator { -public: - struct Result { - bool valid; - std::string message; - }; - - static Result validate(const std::string& password) { - if (password.size() < 8) { - return {false, "Password must be at least 8 characters"}; - } - if (password.size() > 128) { - return {false, "Password too long (max 128)"}; - } - - bool has_upper = false, has_lower = false, has_digit = false, has_special = false; - for (char c : password) { - // std::isupper 等函数参数为 int,char 为有符号且非 ASCII 时是 UB - // 必须 static_cast 后再传入(P1-2) - unsigned char uc = static_cast(c); - if (std::isupper(uc)) has_upper = true; - else if (std::islower(uc)) has_lower = true; - else if (std::isdigit(uc)) has_digit = true; - else has_special = true; - } - - int categories = has_upper + has_lower + has_digit + has_special; - if (categories < 3) { - return {false, "Password must contain at least 3 of: uppercase, lowercase, digit, special character"}; - } - - return {true, "OK"}; - } -}; - -////////////////////////////////// -// IP Rate Limiter (in-memory) -////////////////////////////////// -#include -#include -#include - -class RateLimiter { -public: - struct AttemptInfo { - int count; - std::chrono::steady_clock::time_point first_attempt; - }; - - bool check_and_record(const std::string& ip, int max_attempts = 5, int window_seconds = 300) { - std::lock_guard lock(mtx); - auto now = std::chrono::steady_clock::now(); - - auto& info = attempts[ip]; - - // Reset if window expired - auto elapsed = std::chrono::duration_cast(now - info.first_attempt).count(); - if (elapsed > window_seconds) { - info.count = 0; - info.first_attempt = now; - } - - info.count++; - - // Cleanup old entries periodically - if (attempts.size() > 10000) { - for (auto it = attempts.begin(); it != attempts.end(); ) { - auto e = std::chrono::duration_cast(now - it->second.first_attempt).count(); - if (e > window_seconds) it = attempts.erase(it); - else ++it; - } - } - - return info.count <= max_attempts; - } - -private: - std::unordered_map attempts; - std::mutex mtx; -}; - -////////////////////////////////// -/* -------- 数据库异常\数据库操作失败 -------- */ -class DatabaseException: public ExceptionBase { -public: - using ExceptionBase::ExceptionBase; - - std::string build_message() const override { - return "[Database]" + ExceptionBase::build_message(); - } -}; /* http异常、非200 */ class HttpException: public ExceptionBase { @@ -264,11 +74,7 @@ private: std::string m_url; }; - -//void db_init(); /* 初始化数据库 */ void registerRoutes(HttpServer &server); -std::string generate_captcha_text(); -bool verify_captcha(const std::string& token, const std::string& answer); /* http视图包装器,主要是将异常回显到浏览器 */ http::response wrapperHttp( @@ -276,7 +82,6 @@ http::response wrapperHttp( const std::unordered_map& params, HttpRequestHandler _main); - /* http代理视图,额外接受一个method字符串 */ http::response handleProxy( const http::request& req, @@ -284,486 +89,31 @@ http::response handleProxy( const char *method ); -/* */ -http::response handleLogin( - const http::request& req, - const std::unordered_map& params); - -http::response handleRegister( - const http::request& req, - const std::unordered_map& params); - -/* 发送注册邮箱验证码:pymsg 调 smtp 服务 send_email 路由 */ -http::response handleSendEmailCode( - const http::request& req, - const std::unordered_map& params); - -/* 发送重置密码邮箱验证码:pymsg 调 smtp 服务 send_email 路由(定制重置文案) */ -http::response handleResetSendCode( - const http::request& req, - const std::unordered_map& params); - -/* 重置密码:与注册流程一致,但最后一步由创建用户改为更新密码 */ -http::response handleResetPassword( - const http::request& req, - const std::unordered_map& params); - -http::response handleCaptcha( - const http::request& req, - const std::unordered_map& params); - -http::response handleLogout( - const http::request& req, - const std::unordered_map& params); - -http::response handleRefreshToken( - const http::request& req, - const std::unordered_map& params); - -/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */ -http::response handleWhoami( - const http::request& req, - const std::unordered_map& params); - -/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */ -http::response handleAdminVerify( - const http::request& req, - const std::unordered_map& params); - -/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ -http::response handleCaptchaImage( - const http::request& req, - const std::unordered_map& params); - -/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */ -std::string get_cookie_value(const std::string& cookie_header, const std::string& name); - - -/* headers can't processed */ +/* 特殊请求头过滤(转发时剥离,见 handleProxy) */ bool is_special_header(const std::string &header); /* 请求路径安全性校验(防路径遍历/协议注入/空字节) */ bool is_safe_target(const std::string& target); -/* ===== Token 吊销机制 ===== */ -// Token 验签结果结构体 -struct TokenInfo { - bool valid = false; - std::string username; - std::string jti; // Token 唯一标识(用于单 Token 吊销) - int ver = 1; // 用户 Token 版本号(用于全设备吊销) - bool is_admin = false; - int keep_time = 3600; - int refresh_count = 0; - int max_refresh = 10; - std::string iat; - std::string created_at; - int http_status = 401; // 验签失败时的推荐 HTTP 状态码 -}; - -// 生成 128-bit 随机 JTI(hex 字符串,32 字符) -std::string generate_jti(); - -// 获取用户当前 Token 版本号(不存在则初始化为 1) -int get_user_ver(const std::string& username); - -// 吊销单个 Token(将 jti 加入黑名单直到其过期) -void revoke_jti(const std::string& jti, int keep_time); - -// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效) -void revoke_all_user_tokens(const std::string& username); - -// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token) -// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串 -// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入) -std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token); - -// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查 -// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀) -TokenInfo verify_token(const std::string& raw_token); - /* configure items */ static std::string _ADDR; static int _PORT; static bool _TLS = false; 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 - -/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +/* 下游 pymsg 服务地址(日志推送端点,从 confs [Downstream] 段加载,可被环境变量覆盖) */ static std::string _DB_HOST = "127.0.0.1"; static int _DB_PORT = 8005; -static std::string _CAPTCHA_HOST = "127.0.0.1"; -static int _CAPTCHA_PORT = 8009; -static std::string _SMTP_HOST = "127.0.0.1"; -static int _SMTP_PORT = 8008; /* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ static std::string _CONFS_HOST = "127.0.0.1"; static int _CONFS_PORT = 8010; -/* WebSocket 代理路由:path -> ws://backend-host:port/target - * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ -static std::unordered_map _WS_PROXY_ROUTES; - /* instance items */ static std::unique_ptr _SERVE; -static std::unique_ptr _KEYGEN; -//static std::unique_ptr _SQLDB; -static RateLimiter _LOGIN_RATE; -static RateLimiter _REGISTER_RATE; -static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 -static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 -static RateLimiter _RESET_PASSWORD_RATE; // 重置密码 IP 级限流 -static RateLimiter _RESET_CODE_RATE; // 发送重置密码验证码 IP 级限流 -static RateLimiter _CAPTCHA_IMAGE_RATE; // 获取验证码图片 IP 级限流(防全局 DoS,P1-1) -// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。 -// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。 -// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。 -static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱 -static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱 - -/* pymsg 下游调用超时(秒): - * captcha / smtp verify_email 均为本机内存操作,5s 足够; - * smtp send_email 需完成一次完整外部 SMTP 会话(握手 + TLS + 投递),放宽到 20s。 - * db 调用的默认超时见 DbC.hpp 的 DbC::DEFAULT_TIMEOUT。 */ -static constexpr double _TIMEOUT_CAPTCHA = 5.0; -static constexpr double _TIMEOUT_SMTP_VERIFY = 5.0; -static constexpr double _TIMEOUT_SMTP_SEND = 20.0; - -/* CAPTCHA store: token -> answer (in-memory, with expiry) */ -#include -#include -struct CaptchaEntry { - std::string answer; - std::chrono::steady_clock::time_point created; -}; -static std::unordered_map _CAPTCHA_STORE; -static std::mutex _CAPTCHA_MTX; - -/* Token 吊销机制(P1:Token 吊销缺失修复) - * - * 双轨纯内存方案: - * 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理 - * 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取 - * - * 设计优势: - * - 零 DB 改动:黑名单和版本号全在 gateway 进程内存中 - * - 重启自动清空:gateway 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败 - * - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响 - * - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理 - */ -struct JtiDenylistEntry { - std::chrono::steady_clock::time_point expire_at; -}; -static std::unordered_map _JTI_DENYLIST; -static std::unordered_map _USER_VER_MAP; -static std::mutex _TOKEN_REVOKE_MTX; -static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理 /* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */ static inline LogC& logs() { return LogC::instance(); } -std::string generate_captcha_text() { - static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; - // thread_local:每个线程独立 rng 实例,避免多线程并发调用 dist(rng) 时的数据竞争 - // (static 变量初始化本身线程安全,但 mt19937::operator() 修改内部状态非线程安全) - thread_local std::mt19937 rng(std::random_device{}()); - thread_local std::uniform_int_distribution dist(0, sizeof(chars) - 2); - - std::string text; - for (int i = 0; i < 5; i++) { - text += chars[dist(rng)]; - } - return text; -} - -bool verify_captcha(const std::string& token, const std::string& answer) { - std::lock_guard lock(_CAPTCHA_MTX); - auto it = _CAPTCHA_STORE.find(token); - if (it == _CAPTCHA_STORE.end()) return false; - - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - it->second.created).count(); - if (elapsed > 300) { // 5 min expiry - _CAPTCHA_STORE.erase(it); - return false; - } - - bool match = (it->second.answer == answer); - _CAPTCHA_STORE.erase(it); // one-time use - return match; -} - -/* 调用 captcha 服务(pymsg/TCP :8009)校验图形验证码。 - * Token 一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 - * 与内置 verify_captcha 并存:登录/注册可任选其一接入。 */ -bool verify_captcha_remote(const std::string& token, const std::string& answer) { - try { - pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); - json::value jv_req = { - {"_route", "verify"}, - {"token", token}, - {"answer", answer} - }; - std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_CAPTCHA); - auto jv = json::parse(resp); - auto& obj = jv.as_object(); - if (!obj.at("success").as_bool()) return false; - return obj.at("data").as_object().at("valid").as_bool(); - } catch (const std::exception&) { - return false; - } -} - -/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 - * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ -bool verify_email_code_remote(const std::string& email, const std::string& code) { - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "verify_email"}, - {"email", email}, - {"code", code} - }; - std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_VERIFY); - auto jv = json::parse(resp); - auto& obj = jv.as_object(); - if (!obj.at("success").as_bool()) return false; - return obj.at("data").as_object().at("valid").as_bool(); - } catch (const std::exception&) { - return false; - } -} - -/* ===== Token 吊销机制实现 ===== */ - -std::string generate_jti() { - unsigned char jti_bytes[16]; - RAND_bytes(jti_bytes, 16); - std::string jti; - static const char hex[] = "0123456789abcdef"; - jti.reserve(32); - for (int i = 0; i < 16; i++) { - jti += hex[jti_bytes[i] >> 4]; - jti += hex[jti_bytes[i] & 0x0F]; - } - return jti; -} - -int get_user_ver(const std::string& username) { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it = _USER_VER_MAP.find(username); - if (it == _USER_VER_MAP.end()) { - _USER_VER_MAP[username] = 1; - return 1; - } - return it->second; -} - -void revoke_jti(const std::string& jti, int keep_time) { - if (jti.empty()) return; - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time); - _JTI_DENYLIST[jti] = {expire}; - - // 超过阈值时批量清理过期条目 - if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) { - auto now = std::chrono::steady_clock::now(); - for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) { - if (now > it->second.expire_at) - it = _JTI_DENYLIST.erase(it); - else - ++it; - } - } -} - -void revoke_all_user_tokens(const std::string& username) { - if (username.empty()) return; - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it = _USER_VER_MAP.find(username); - if (it == _USER_VER_MAP.end()) { - _USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2 - } else { - it->second++; - } - logs().info("gateway", "用户所有Token已吊销: " + username, std::nullopt, - json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}})); -} - -std::string extract_token_from_request(const http::request& req, std::string& out_cookie_token) { - out_cookie_token.clear(); - std::string raw; - - // 1. 优先从 Authorization: Bearer 头获取 - auto it_auth = req.base().find("Authorization"); - if (it_auth != req.base().end()) { - raw = std::string(it_auth->value()); - } - - // 2. 回退从 Cookie q4_token 获取 - if (raw.empty()) { - auto cit = req.base().find("Cookie"); - if (cit != req.base().end()) { - std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token"); - if (!cookie_token.empty()) { - raw = "Bearer " + cookie_token; - out_cookie_token = cookie_token; - } - } - } - - // 去掉 "Bearer " 前缀 - if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") { - return raw.substr(7); - } - return raw; // 可能为空或不含前缀,由调用方处理 -} - -// 清理 denylist 中指定 jti(如果已过期则删除) -static void cleanup_jti_if_expired(const std::string& jti) { - auto now = std::chrono::steady_clock::now(); - auto it = _JTI_DENYLIST.find(jti); - if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) { - _JTI_DENYLIST.erase(it); - } -} - -TokenInfo verify_token(const std::string& raw_token) { - TokenInfo info; - info.valid = false; - - if (raw_token.empty()) { - info.http_status = 401; - return info; - } - - // 解析 base64data_signature - size_t idx = raw_token.find("_"); - if (idx == std::string::npos) { - info.http_status = 400; - return info; - } - - std::string data = raw_token.substr(0, idx); - std::string sign = raw_token.substr(idx + 1); - std::string auth; - try { - auth = Base64::base64_decode_to_string(data); - } catch (const std::exception&) { - info.http_status = 400; - return info; - } - - // RSA 验签 - auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); - if (!publ.Verify(auth, sign)) { - info.http_status = 401; - return info; - } - - // 解析 payload - json::value jv_t; - try { - jv_t = json::parse(auth); - } catch (const std::exception&) { - info.http_status = 400; - return info; - } - if (!jv_t.is_object()) { - info.http_status = 400; - return info; - } - - json::object& obj = jv_t.as_object(); - - // 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况) - try { - info.username = json::value_to(obj.at("user")); - } catch (...) { - info.http_status = 400; - return info; - } - - // 过期检查 - auto it_created = obj.find("created_at"); - if (it_created != obj.end() && it_created->value().is_string()) { - info.created_at = json::value_to(it_created->value()); - } - auto it_keep = obj.find("keep_time"); - if (it_keep != obj.end() && it_keep->value().is_int64()) { - info.keep_time = static_cast(it_keep->value().as_int64()); - } - if (!info.created_at.empty()) { - auto created_tp = LocalTime::parse(info.created_at); - if (created_tp) { - auto now = std::chrono::system_clock::now(); - auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time); - if (now > expire_tp) { - info.http_status = 401; - return info; - } - } - } - - // is_admin - auto it_admin = obj.find("is_admin"); - if (it_admin != obj.end() && it_admin->value().is_bool()) { - info.is_admin = it_admin->value().as_bool(); - } - - // refresh_count / max_refresh / iat - auto it_rc = obj.find("refresh_count"); - if (it_rc != obj.end() && it_rc->value().is_int64()) - info.refresh_count = static_cast(it_rc->value().as_int64()); - auto it_mr = obj.find("max_refresh"); - if (it_mr != obj.end() && it_mr->value().is_int64()) - info.max_refresh = static_cast(it_mr->value().as_int64()); - auto it_iat = obj.find("iat"); - if (it_iat != obj.end() && it_iat->value().is_string()) - info.iat = json::value_to(it_iat->value()); - - // jti 黑名单检查(单 Token 吊销) - auto it_jti = obj.find("jti"); - if (it_jti != obj.end() && it_jti->value().is_string()) { - info.jti = json::value_to(it_jti->value()); - std::lock_guard lock(_TOKEN_REVOKE_MTX); - cleanup_jti_if_expired(info.jti); // 惰性清理 - if (_JTI_DENYLIST.count(info.jti)) { - info.http_status = 401; - return info; - } - } - - // 版本号检查(全设备吊销:改密后版本号递增) - // 兼容旧 Token 无 ver 字段:视为 ver=1 - auto it_ver = obj.find("ver"); - int token_ver = 1; - if (it_ver != obj.end() && it_ver->value().is_int64()) { - token_ver = static_cast(it_ver->value().as_int64()); - } - info.ver = token_ver; - { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it_uv = _USER_VER_MAP.find(info.username); - int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; - if (token_ver < current_ver) { - info.http_status = 401; - return info; - } - } - - // 全部通过 - info.valid = true; - info.http_status = 200; - return info; -} - /* -------- 信号处理 -------- * * 安全设计原则: @@ -900,43 +250,10 @@ int main(int argc, char **argv) { _TLS = cf.get("Server.tls", false); _DEFT_URL = cf.get("Proxy.default", ""); _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", ""), ';'); - /* 下游服务地址 */ + /* 下游服务地址(日志推送端点) */ _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); _DB_PORT = cf.get("Downstream.db_port", 8005); - _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); - _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); - _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); - _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); - - /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ - { - std::string ws_cfg = cf.get("WsProxy.routes", ""); - if (!ws_cfg.empty()) { - auto entries = split(ws_cfg, ';'); - for (auto& entry : entries) { - auto eq = entry.find('='); - if (eq != std::string::npos) { - std::string path = entry.substr(0, eq); - std::string url = entry.substr(eq + 1); - auto trim = [](std::string& s) { - while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); - while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); - }; - trim(path); trim(url); - if (!path.empty() && !url.empty()) { - _WS_PROXY_ROUTES[path] = url; - } - } - } - } - } /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); @@ -949,16 +266,6 @@ int main(int argc, char **argv) { int env_db_port = ConfIni::env_int("DB_PORT", -1); if (env_db_port > 0) _DB_PORT = env_db_port; - std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); - if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; - int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); - if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; - - std::string env_smtp_host = ConfIni::env("SMTP_HOST"); - if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; - int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); - if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; - /* 4. 配置完整性校验 */ if (_ADDR.empty() || _PORT == -1) { std::cerr << "[gateway] 致命错误: 配置项 [Server] address/port 缺失" @@ -973,31 +280,31 @@ int main(int argc, char **argv) { std::cerr << "[gateway] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; return 1; } + + /* 5. loopback 强制自检(v0.9.0 前置拓扑): + * gateway 不再验签,身份完全信任 ca 注入的 X-Auth-User 头, + * 因此必须只监听 loopback;非 loopback 地址意味着外部可直连伪造身份,拒绝启动 */ + if (_ADDR != "127.0.0.1" && _ADDR != "::1" && _ADDR != "localhost") { + std::cerr << "[gateway] 致命错误: 前置拓扑(waf → ca → gateway)下 gateway 仅允许 loopback 监听," + << "当前 address=" << _ADDR << ",请改为 127.0.0.1" << std::endl; + return 1; + } } - /* 初始化下游客户端地址 */ - DbC::init(_DB_HOST, _DB_PORT); + /* 初始化日志推送端点 */ logs().set_endpoint(_DB_HOST, _DB_PORT); std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; - std::cout << "[gateway] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT - << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT - << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 - // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 + // HTTP 模式用于 ca 前置后的内部转发链路 if (_TLS) { _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); } else { _SERVE = std::make_unique (_ADDR, _PORT, 4); } - _KEYGEN = std::make_unique (); - //_SQLDB = std::make_unique (_DB_PATH); - /* 迁移到了外部 */ - //db_init(); - /* */ registerRoutes(*_SERVE); @@ -1071,151 +378,19 @@ int main(int argc, char **argv) { if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { std::cerr << "[gateway] 警告:日志刷新超时,退出" << std::endl; log_flush_thread.detach(); - } else { - log_flush_thread.join(); } + /* 非超时分支不得再 join:async 已完成 join,线程已不可 join, + * 重复 join 抛 std::system_error 导致 terminate(审计 A1) */ } std::cerr << "[gateway] 已退出" << std::endl; _SERVE.reset(); - _KEYGEN.reset(); -} - -//void db_init() { -// -// std::string sql = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Users'"; -// -// auto flag = false; -// { -// auto stmt = _SQLDB->stmt(sql); -// if (stmt.step()) { -// auto num = stmt.read_int(0); -// if (num == 0) { -// flag = true; -// } -// } -// } -// -// if (flag) { -// std::cout << "Start database init..." << std::endl; -// std::string sql_crtt = -// "CREATE TABLE Users (\n" -// "id INTEGER PRIMARY KEY AUTOINCREMENT,\n" -// "email TEXT UNIQUE NOT NULL,\n" -// "password_hash TEXT NOT NULL,\n" -// "create_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" -// "last_login DATETIME,\n" -// "is_active BOOLEAN DEFAULT 1\n" -// ");\n\n" -// "CREATE INDEX idx_users_email ON users(email);\n"; -// -// _SQLDB->exec(sql_crtt); -// -// // No hardcoded admin - first registered user becomes admin -// std::cout << "done (no default admin - register manually)" << std::endl; -// } -// -// else { -// std::cout << "The database already init" << std::endl; -// } -//} - - -/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。 - * 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */ -static bool verifyWsAdminToken(const http::request& req, - const std::string& target_url, - std::string& out_target) { - out_target.clear(); - try { - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (raw_token.empty()) return false; - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) return false; - - // 必须是管理员 - if (!ti.is_admin) { - logs().warning("gateway", "WS proxy rejected: non-admin user", - std::nullopt, std::string(req.target())); - return false; - } - - out_target = target_url; - return true; - } - catch (const std::exception& e) { - std::cerr << "[WS proxy auth error] " << e.what() << std::endl; - return false; - } } /* 注册http路由 */ void registerRoutes(HttpServer &server) { - server.post("/captcha", [](const auto& req, const auto& params) { - return handleCaptcha(req, params); - }); - /* const http::request& req, const std::unordered_map& params */ - server.post("/login", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) {return handleLogin(req, params);}); - }); - - server.post("/register", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleRegister(req, params); }); - }); - - /* 发送注册邮箱验证码(须在 post("*") 通配符之前注册) */ - server.post("/register/send_code", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleSendEmailCode(req, params); }); - }); - - /* 发送重置密码邮箱验证码(须在 post("*") 通配符之前注册) */ - server.post("/reset/send_code", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleResetSendCode(req, params); }); - }); - - /* 重置密码(须在 post("*") 通配符之前注册) */ - server.post("/reset_password", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleResetPassword(req, params); }); - }); - - server.post("/logout", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](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); }); - }); - - server.post("/whoami", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleWhoami(req, params); }); - }); - - /* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */ - server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleAdminVerify(req, params); }); - }); - - /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ - server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { - return wrapperHttp(req, params, - [](const auto& req, const auto& params) { return handleCaptchaImage(req, params); }); - }); - server.get("*", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, [](const auto& req, const auto& params) { return handleProxy(req, params, "get"); }); @@ -1226,20 +401,6 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleProxy(req, params, "post"); }); }); - /* WebSocket 反向代理(仅管理员): - * ws_proxy 回调在 HTTP Upgrade 握手阶段触发, - * 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */ - for (const auto& [path, url] : _WS_PROXY_ROUTES) { - server.ws_proxy(path, [url](const http::request& upgradeReq) -> std::string { - std::string target; - if (verifyWsAdminToken(upgradeReq, url, target)) { - return target; - } - return ""; - }); - std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl; - } - } /* Http视图包装器 @@ -1268,8 +429,9 @@ catch (const HttpException& e) { json::serialize(json::value{{"url", e.url()}, {"status", e.status()}})); json::value jv_r = { {"success", false}, - {"message", e.what()} - /* {"message", "Registration failed"} */ + /* 客户端只给纯消息(e.message());e.what() 的 [Http][Error NNNN] 内部前缀 + * 仅保留在服务端 stderr/集中日志,不外泄(审计 A8) */ + {"message", e.message()} }; res.set(http::field::content_type, "application/json"); res.result(e.status()); @@ -1302,1029 +464,12 @@ catch (const std::exception& e) { return res; } -// CAPTCHA endpoint: generates a random text, stores it, returns a simple token -// by shuiyu -http::response handleCaptcha( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - std::string text = generate_captcha_text(); - - // Generate a token for this captcha - unsigned char token_bytes[16]; - RAND_bytes(token_bytes, 16); - std::string token; - static const char hex[] = "0123456789abcdef"; - for (int i = 0; i < 16; i++) { - token += hex[token_bytes[i] >> 4]; - token += hex[token_bytes[i] & 0x0F]; - } - - { - std::lock_guard lock(_CAPTCHA_MTX); - _CAPTCHA_STORE[token] = {text, std::chrono::steady_clock::now()}; - } - - json::value jv = { - {"token", token}, - {"text", text} - }; - - res.set(http::field::content_type, "application/json"); - res.result(http::status::ok); - res.body() = json::serialize(jv); - res.prepare_payload(); - return res; -} - -/* 处理登陆 +/* http 代理转发视图(v0.9.0 纯转发版) * - * /login */ -http::response handleLogin( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - std::string client_ip = ""; - auto it_host = req.base().find("X-Forwarded-For"); - if (it_host != req.base().end()) - client_ip = std::string(it_host->value()); - else - client_ip = "direct"; - - if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) - THROW_EX_CUSTOM(HttpException, 2001, "Too many login attempts. Please try again later.", 429, "/login"); - - // parse json req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const boost::wrapexcept& e) { - THROW_EX_CUSTOM(HttpException, 2001, "Invalid_json_Argument", 400, "/login"); - } - - if (!jv.is_object()) THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument", 400, "/login"); - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // CAPTCHA verification 验证码(对接 captcha pymsg 服务,图形码 Token 一次性消费) - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it == obj.end() || captcha_ans_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 2002, "Bad_Request", 400, "/login"); - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha_remote(ct, ca)) THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/login"); - - std::string stored_hash = ""; - { - // Fetch stored hash - support both PBKDF2 and legacy SHA256 - //std::string sql = "SELECT password_hash FROM Users WHERE email=?;"; - //auto stmt = _SQLDB->stmt(sql); - //stmt.bind_text(1, username); - //if (stmt.step()) { - // stored_hash = std::string(reinterpret_cast(stmt.read_text(0))); - //} - - // 新版调用db模块(通过 MsgClient pymsg TCP 协议) - // 【保留】旧版 HTTP 调用方式: - // auto jv_db_r = DbC::call_http(jv_select); - json::value jv_select = { - {"action", "select"}, - {"table", "Users"}, - {"where", json::object{ - {"username", username}, - }} - }; - auto jv_db_r = DbC::call_msg(jv_select); - auto obj_r = jv_db_r.as_object(); - auto success = obj_r["success"].as_bool(); - if (!success) THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); - auto obj_rd = obj_r.at("data").as_array(); - /* 用户不存在时 data 为空数组:直接 at(0) 会抛异常被 wrapper 兜底成 500, - * 此处保持 stored_hash 为空,统一走下面的 401 分支 */ - if (!obj_rd.empty()) { - auto obj_ru = obj_rd.at(0).as_object(); - stored_hash = obj_ru.at("password_hash").as_string(); - } - } - - - - bool auth_ok = false; - if (!stored_hash.empty()) { - if (stored_hash.substr(0, 7) == "pbkdf2:") - // New PBKDF2 hash - auth_ok = PBKDF2Hasher::verify_password(password, stored_hash); - //else { - // Legacy SHA256 hash - verify then auto-migrate - //auto legacy_hash = SHA256::sha256(password); - //if (legacy_hash == stored_hash) { - // auth_ok = true; - // Auto-migrate to PBKDF2 - // auto new_hash = PBKDF2Hasher::hash_password(password); - // std::string sql_migrate = "UPDATE Users SET password_hash=? WHERE email=?;"; - // auto stmt_mig = _SQLDB->stmt(sql_migrate); - // stmt_mig.bind_text(1, new_hash); - // stmt_mig.bind_text(2, username); - // stmt_mig.step(); - // std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl; - //} - } - else { - /* 用户不存在:执行一次等价的 dummy PBKDF2 校验拉平响应时序, - * 防止通过响应时间差异枚举有效用户名(用户不存在/密码错误一律 401) */ - static const std::string dummy_hash = - "pbkdf2:100000:00000000000000000000000000000000:" - "0000000000000000000000000000000000000000000000000000000000000000"; - PBKDF2Hasher::verify_password(password, dummy_hash); - } - - if (!auth_ok) THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password", 401, "/login"); - - // Update last_login - //std::string sql_update = "UPDATE Users SET last_login=datetime('now','localtime') WHERE email=?;"; - //auto stmt_upd = _SQLDB->stmt(sql_update); - //stmt_upd.bind_text(1, username); - //stmt_upd.step(); - // - // 新版调用db模块:values 中的 "$now" 哨兵由 db 日期适配层替换为服务端当前 UTC 时间 - //(此前因 db 无法处理日期列而注释,dbv2 增加 _coerce_value 后可用)。 - json::value jv_upd = { - {"action", "update"}, - {"table", "Users"}, - {"values", json::object{ - {"last_login", "$now"}, - }}, - {"where", json::object{ - {"username", username}, - }} - }; - auto jv_upd_r = DbC::call_msg(jv_upd); - if (!jv_upd_r.as_object()["success"].as_bool()) - THROW_EX_CUSTOM(HttpException, 2102, "Login failed, please try again later", 500, "/login"); - - - /* 生成令牌 */ - 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(); - - /* Token 吊销支持:签发新 jti + 当前版本号 */ - std::string jti = generate_jti(); - int user_ver = get_user_ver(username); - - json::value jv_t = { - {"user", username}, - {"jti", jti}, - {"ver", user_ver}, - {"iat", LocalTime::getCurrent()}, - {"created_at", LocalTime::getCurrent()}, - {"keep_time", _TOKEN_KEEP_TIME}, - {"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); - std::string sign = _PRIV.Sign(data); - std::string token = auth + "_" + sign; - - - /* 生成返回json TODO: 专门写个类做这事 */ - json::value jv_rr = { - {"success", true}, - {"data", json::object{{ - "token", token}} - } - }; - res.set("Connection", "close"); - - /* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */ - { - std::string ck = "q4_token=" + token + - "; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" + - std::to_string(_TOKEN_KEEP_TIME); - res.set("Set-Cookie", ck); - } - - logs().info("gateway", "登录成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - res.result(http::status::ok); - res.body() = json::serialize(jv_rr); - res.prepare_payload(); - return res; -} - -/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */ -http::response handleLogout( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - /* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (!raw_token.empty()) { - TokenInfo ti = verify_token(raw_token); - if (ti.valid && !ti.jti.empty()) { - revoke_jti(ti.jti, ti.keep_time); - logs().info("gateway", "Token 已吊销: " + ti.username, std::nullopt, - json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}})); - } - } - - res.set("Set-Cookie", - "q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"); - res.set("Connection", "close"); - res.result(http::status::ok); - res.body() = R"({"success": true, "message": "logged out"})"; - res.prepare_payload(); - return res; -} - -/* 当前用户身份查询 /whoami - * - * 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。 - * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 - * 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */ -http::response handleWhoami( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - res.set(http::field::content_type, "application/json"); - res.set("Connection", "close"); - - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - - if (raw_token.empty()) { - res.result(http::status::ok); - res.body() = R"({"login": false})"; - res.prepare_payload(); - return res; - } - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - res.result(http::status::ok); - res.body() = R"({"login": false})"; - res.prepare_payload(); - return res; - } - - json::value jv_r = { - {"login", true}, - {"user", ti.username}, - {"is_admin", ti.is_admin} - }; - - /* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */ - if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat; - if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at; - if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time; - - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 内部服务间管理员验签视图:POST /admin/verify - * - * 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。 - * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 - * gateway 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。 - * - * 响应:{ success, is_admin, user } - * - success=true, is_admin=true → 有效管理员(放行) - * - success=true, is_admin=false → 非管理员(拒绝) - * - success=false → 无 Token / 验签失败 / 过期(拒绝) - * 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */ -http::response handleAdminVerify( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - res.set(http::field::content_type, "application/json"); - res.set("Connection", "close"); - - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - - if (raw_token.empty()) { - res.result(http::status::unauthorized); - res.body() = R"({"success": false, "is_admin": false})"; - res.prepare_payload(); - return res; - } - - /* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - res.result(http::status::unauthorized); - res.body() = R"({"success": false, "is_admin": false})"; - res.prepare_payload(); - return res; - } - - json::value jv_r = { - {"success", true}, - {"is_admin", ti.is_admin}, - {"user", ti.username} - }; - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - 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"); - - /* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */ - std::string cookie_token; - std::string raw_token = extract_token_from_request(req, cookie_token); - if (raw_token.empty()) - THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token"); - - /* 正常过期(非吊销/版本错误)才允许宽限期刷新: - * - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝 - * - 版本号落后(改密后旧token)→ 401 拒绝 - * 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。 - * 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。 - * 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */ - - // 重新做一次不含过期检查的验签(判断是否被吊销) - bool token_revoked = false; - std::string old_jti; - std::string username; - std::string iat_str; - int keep_time = _TOKEN_KEEP_TIME; - int refresh_count = 0; - int max_refresh = _TOKEN_MAX_REFRESH; - bool is_admin = false; - int user_ver = 1; - { - if (raw_token.find("_") == std::string::npos) - THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); - size_t idx2 = raw_token.find("_"); - std::string data = raw_token.substr(0, idx2); - std::string sign = raw_token.substr(idx2 + 1); - std::string auth; - try { auth = Base64::base64_decode_to_string(data); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token"); - } - auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); - if (!_PUBL.Verify(auth, sign)) - THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token"); - json::value jv_t; - try { jv_t = json::parse(auth); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); - } - if (!jv_t.is_object()) - THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token"); - json::object& obj = jv_t.as_object(); - username = json::value_to(obj.at("user")); - iat_str = json::value_to(obj.at("iat")); - keep_time = json::value_to(obj.at("keep_time")); - refresh_count = json::value_to(obj.at("refresh_count")); - max_refresh = json::value_to(obj.at("max_refresh")); - auto it_admin = obj.find("is_admin"); - if (it_admin != obj.end() && it_admin->value().is_bool()) - is_admin = it_admin->value().as_bool(); - // 检查 jti 黑名单 - auto it_jti = obj.find("jti"); - if (it_jti != obj.end() && it_jti->value().is_string()) { - old_jti = json::value_to(it_jti->value()); - std::lock_guard lock(_TOKEN_REVOKE_MTX); - if (_JTI_DENYLIST.count(old_jti)) - token_revoked = true; - } - // 检查版本号 - auto it_ver = obj.find("ver"); - int token_ver = 1; - if (it_ver != obj.end() && it_ver->value().is_int64()) - token_ver = static_cast(it_ver->value().as_int64()); - { - std::lock_guard lock(_TOKEN_REVOKE_MTX); - auto it_uv = _USER_VER_MAP.find(username); - user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1; - if (token_ver < user_ver) - token_revoked = true; - } - } - - if (token_revoked) - THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.", - 401, "/refresh_token"); - - /* 刷新次数检查 */ - 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"); - - /* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */ - if (!old_jti.empty()) { - revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER); - } - - /* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */ - std::string new_jti = generate_jti(); - auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem()); - json::value jv_new = { - {"user", username}, - {"jti", new_jti}, - {"ver", user_ver}, - {"iat", iat_str}, - {"created_at", LocalTime::getCurrent()}, - {"keep_time", keep_time}, - {"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),浏览器无法直接访问, - * 由本路由做 HTTP → pymsg 转换:调 image 路由拿到 base64 PNG 与 token, - * 解码后以 image/png + X-Captcha-Token 响应头返回(与旧直出接口契约一致)。 - */ -http::response handleCaptchaImage( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - /* IP 级速率限制(P1-1 修复):单 IP 每分钟最多 10 次, - 防止单一攻击者打满全局限流导致全站验证码不可用 */ - 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 (!_CAPTCHA_IMAGE_RATE.check_and_record(client_ip, 10, 60)) - THROW_EX_CUSTOM(HttpException, 2403, - "Too many captcha requests. Please try again later.", 429, "/captcha/image"); - - /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ - pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); - std::string resp; - try { - resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); - } catch (const std::exception& e) { - THROW_EX_CUSTOM(HttpException, 2400, "Captcha service unavailable", 502, "/captcha/image"); - } - - /* 解析响应(格式错误同样视为 502) */ - std::string token; - std::string png; - try { - auto jv = json::parse(resp); - auto& obj = jv.as_object(); - if (!obj.at("success").as_bool()) { - // P3-5: 依据 error 信息映射准确 HTTP 状态码 - std::string err_msg = json::value_to(obj.at("error")); - if (err_msg.find("rate_limit") != std::string::npos) - THROW_EX_CUSTOM(HttpException, 2401, "Too many captcha requests", 429, "/captcha/image"); - if (err_msg.find("store") != std::string::npos || err_msg.find("full") != std::string::npos) - THROW_EX_CUSTOM(HttpException, 2401, "Captcha service overloaded", 503, "/captcha/image"); - THROW_EX_CUSTOM(HttpException, 2401, "Captcha service error", 502, "/captcha/image"); - } - auto& data = obj.at("data").as_object(); - token = json::value_to(data.at("token")); - auto image_b64 = json::value_to(data.at("image")); - png = Base64::base64_decode_to_string(image_b64); - } catch (const HttpException&) { - throw; - } catch (const std::exception& e) { - THROW_EX_CUSTOM(HttpException, 2402, "Bad captcha response", 502, "/captcha/image"); - } - - res.set(http::field::content_type, "image/png"); - res.set("X-Captcha-Token", token); - res.set("Cache-Control", "no-store"); /* 验证码图片禁止缓存 */ - res.set("Connection", "close"); - res.result(http::status::ok); - res.body() = std::move(png); - res.prepare_payload(); - return res; -} - -/* 处理注册 /register */ -http::response handleRegister( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - 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 (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) - THROW_EX_CUSTOM(HttpException, 3001, "Too many registration attempts. Please try again later.", - 429, "/register"); - - // parse the req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const boost::wrapexcept& e) { - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register"); - - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // Strong password check - auto pw_result = PasswordValidator::validate(password); - if (!pw_result.valid) - THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/register"); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(username, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register"); - - // CAPTCHA verification - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha(ct, ca)) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register"); - } - - // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) - auto ecode_it = obj.find("email_code"); - if (ecode_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/register"); - std::string email_code = std::string(ecode_it->value().as_string()); - if (!verify_email_code_remote(username, email_code)) - THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/register"); - - // generate the hash of password - auto password_hash = PBKDF2Hasher::hash_password(password); - - //std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));"; - //auto stmt = _SQLDB->stmt(sql); - //stmt.bind_text(1, username); - //stmt.bind_text(2, password_hash); - //stmt.step(); - // - // 新版调用db模块(通过 MsgClient pymsg TCP 协议) - // 【保留】旧版 HTTP 调用方式: - // auto jv_result = DbC::call_http(jv_insert); - json::value jv_insert = { - {"action", "insert"}, - {"table", "Users"}, - {"values", json::object{ - {"username", username}, - {"password_hash", password_hash} - }} - }; - auto jv_result = DbC::call_msg(jv_insert); - auto obj_r = jv_result.as_object(); - auto success = obj_r["success"].as_bool(); - if (!success) THROW_EX_CUSTOM(HttpException, 2202, "Registration failed, please try again later", 500, "/register"); - auto jv_r = json::value{{"success", success}}; - - logs().info("gateway", "注册成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 处理发送注册邮箱验证码 /register/send_code - * - * 经 pymsg 调 smtp 服务 send_email 路由,向目标邮箱发送 6 位验证码。 - * 限流 5 次/10 分钟/IP;smtp 不可用返回 502(fail-closed)。 */ -http::response handleSendEmailCode( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - 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 (!_EMAIL_CODE_RATE.check_and_record(client_ip, 5, 600)) - THROW_EX_CUSTOM(HttpException, 3101, "Too many code requests. Please try again later.", - 429, "/register/send_code"); - - // parse the req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); - - auto& obj = jv.as_object(); - auto email_it = obj.find("email"); - if (email_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/register/send_code"); - auto email = std::string(email_it->value().as_string()); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(email, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); - - // CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1) - // captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。 - // fail-closed:captcha 服务不可用或响应异常一律视为校验失败。 - auto captcha_token_it = obj.find("captcha_token"); - auto captcha_answer_it = obj.find("captcha_answer"); - if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code"); - if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), - std::string(captcha_answer_it->value().as_string()))) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code"); - - // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) - // 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。 - if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) - THROW_EX_CUSTOM(HttpException, 3104, - "Too many code requests for this email. Please try again later.", - 429, "/register/send_code"); - if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) - THROW_EX_CUSTOM(HttpException, 3105, - "Daily email limit exceeded. Please try again tomorrow.", - 429, "/register/send_code"); - - // 调 smtp 服务(pymsg/TCP :8008)发送验证码 - bool sent = false; - int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 - std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "send_email"}, - {"email", email} - }; - 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()) { - 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) { - // 同邮箱发送冷却:按限流语义返回 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}})); - - auto jv_r = json::value{{"success", true}}; - res.result(http::status::ok); - res.set(http::field::content_type, "application/json"); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 发送重置密码邮箱验证码 /reset/send_code - * - * 与注册发码(/register/send_code)结构一致,区别在定制"重置密码"主题与文案: - * 经 pymsg 调 smtp 服务 send_email 路由(subject/content 字段覆盖默认注册文案)。 - * 限流 5 次/10 分钟/IP;CAPTCHA 强制校验;smtp 不可用返回 502(fail-closed)。 */ -http::response handleResetSendCode( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - 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 (!_RESET_CODE_RATE.check_and_record(client_ip, 5, 600)) - THROW_EX_CUSTOM(HttpException, 3201, "Too many code requests. Please try again later.", - 429, "/reset/send_code"); - - // parse the req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); - - auto& obj = jv.as_object(); - auto email_it = obj.find("email"); - if (email_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset/send_code"); - auto email = std::string(email_it->value().as_string()); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(email, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset/send_code"); - - // CAPTCHA 强制校验(防邮件轰炸,与 /register/send_code 一致) - // 经 captcha:8009 pymsg 校验,Token 一次性消费;fail-closed。 - auto captcha_token_it = obj.find("captcha_token"); - auto captcha_answer_it = obj.find("captcha_answer"); - if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/reset/send_code"); - if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), - std::string(captcha_answer_it->value().as_string()))) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset/send_code"); - - // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) - // 与注册发码共用同一配额,防止重置/注册双通道叠加绕过邮箱轰炸防护 - if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) - THROW_EX_CUSTOM(HttpException, 3104, - "Too many code requests for this email. Please try again later.", - 429, "/reset/send_code"); - if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) - THROW_EX_CUSTOM(HttpException, 3105, - "Daily email limit exceeded. Please try again tomorrow.", - 429, "/reset/send_code"); - - // 调 smtp 服务(pymsg/TCP :8008)发送重置密码验证码(定制文案) - bool sent = false; - int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 - std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) - try { - pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); - json::value jv_req = { - {"_route", "send_email"}, - {"email", email}, - {"subject", "QSONT.me - Password Reset"}, - {"content", "您正在重置 QSONT.me 账号密码,验证码为 {code},10 分钟内有效。如非本人操作,请忽略本邮件。"} - }; - 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()) { - 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, "/reset/send_code"); - } - - 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, "/reset/send_code"); - THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/reset/send_code"); - } - - logs().info("gateway", "重置密码验证码邮件发送成功: " + email, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - auto jv_r = json::value{{"success", true}}; - res.result(http::status::ok); - res.set(http::field::content_type, "application/json"); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - -/* 处理重置密码 /reset_password - * - * 与注册流程一致,最后一步由"创建用户"改为"更新密码": - * 校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ 邮箱验证码(smtp verify_email,一次性) - * → 生成 PBKDF2 哈希 → UPDATE Users SET password_hash WHERE username - * 防枚举:用户不存在(update 匹配 0 行)时仍统一返回成功,不泄露邮箱是否已注册。 - * CAPTCHA 走 captcha:8009 远端校验(与前端 /captcha/image 的 token 匹配)。 - */ -http::response handleResetPassword( - const http::request& req, - const std::unordered_map& params) -{ - http::response res; - res.version(req.version()); - - // IP rate limit check - 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 (!_RESET_PASSWORD_RATE.check_and_record(client_ip, 5, 600)) - THROW_EX_CUSTOM(HttpException, 3301, "Too many reset attempts. Please try again later.", - 429, "/reset_password"); - - // parse the req body - json::value jv; - try { jv = json::parse(req.body()); } - catch (const std::exception&) { - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); - } - - if (!jv.is_object()) - THROW_EX_CUSTOM(HttpException, 3002, "Invalid Argument", 401, "/reset_password"); - - auto& obj = jv.as_object(); - auto username = std::string(obj.at("username").as_string()); - auto password = std::string(obj.at("password").as_string()); - - // Strong password check - auto pw_result = PasswordValidator::validate(password); - if (!pw_result.valid) - THROW_EX_CUSTOM(HttpException, 3003, pw_result.message, 401, "/reset_password"); - - // Email format check - std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); - if (!std::regex_match(username, email_regex)) - THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/reset_password"); - - // CAPTCHA verification(可选,与注册一致;走 captcha 服务远端校验) - auto captcha_it = obj.find("captcha_token"); - auto captcha_ans_it = obj.find("captcha_answer"); - if (captcha_it != obj.end() && captcha_ans_it != obj.end()) { - std::string ct = std::string(captcha_it->value().as_string()); - std::string ca = std::string(captcha_ans_it->value().as_string()); - if (!verify_captcha_remote(ct, ca)) - THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/reset_password"); - } - - // 邮箱验证码校验(smtp 服务,一次性消费;须放在所有本地校验通过之后、写库之前) - auto ecode_it = obj.find("email_code"); - if (ecode_it == obj.end()) - THROW_EX_CUSTOM(HttpException, 3005, "Email verification code required", 400, "/reset_password"); - std::string email_code = std::string(ecode_it->value().as_string()); - if (!verify_email_code_remote(username, email_code)) - THROW_EX_CUSTOM(HttpException, 3006, "Invalid or expired email verification code", 400, "/reset_password"); - - // generate the hash of password - auto password_hash = PBKDF2Hasher::hash_password(password); - - // 更新密码:UPDATE Users SET password_hash=? WHERE username=?(经 db 服务 pymsg execute) - json::value jv_update = { - {"action", "update"}, - {"table", "Users"}, - {"values", json::object{ - {"password_hash", password_hash} - }}, - {"where", json::object{ - {"username", username} - }} - }; - auto jv_result = DbC::call_msg(jv_update); - auto obj_r = jv_result.as_object(); - if (!obj_r["success"].as_bool()) - THROW_EX_CUSTOM(HttpException, 2202, "Password reset failed, please try again later", 500, "/reset_password"); - - /* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */ - revoke_all_user_tokens(username); - - // 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册 - logs().info("gateway", "重置密码成功: " + username, std::nullopt, - json::serialize(json::value{{"ip", client_ip}})); - - auto jv_r = json::value{{"success", true}}; - res.result(http::status::ok); - res.body() = json::serialize(jv_r); - res.prepare_payload(); - return res; -} - + * 认证职责已迁至 ca:gateway 不验签,仅做存在性不变量检查—— + * 显式携带 Target-Url 的受保护请求必须带有 ca 注入的 X-Auth-User 头, + * 缺失说明请求未经 ca(如直连 8445),一律 401。 + * X-Auth-User / X-Auth-Admin 头不在 is_special_header 剥离列表中,原样透传给下游。 */ http::response handleProxy( const http::request& req, const std::unordered_map& params, @@ -2335,7 +480,6 @@ http::response handleProxy( std::string t_url = ""; std::string url = ""; - std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */ bool is_public_target = false; { /* 大写的Target-Url */ @@ -2369,23 +513,22 @@ http::response handleProxy( url += "/"; url += target_path; - /* 提取 Token(Authorization 优先,回退 Cookie) */ - std::string raw_token = extract_token_from_request(req, cookie_token); - if (!is_public_target) { - /* 需要鉴权的目标:统一验签(RSA + 过期 + jti黑名单 + 版本号) */ - if (raw_token.empty()) - THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); - - TokenInfo ti = verify_token(raw_token); - if (!ti.valid) { - if (ti.http_status == 400) - THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*"); - THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); + /* 存在性不变量(非验签):受保护请求必须携带 ca 注入的 X-Auth-User。 + * Beast name_string() 保留线上原始大小写,统一转小写比较 */ + bool has_auth_user = false; + for (auto const& field : req) { + std::string h; + h.reserve(field.name_string().size()); + for (unsigned char c : field.name_string()) + h.push_back(static_cast(std::tolower(c))); + if (h == "x-auth-user" && !field.value().empty()) { + has_auth_user = true; + break; + } } - } else { - /* 公开目标不做凭证校验,也不向下游注入 Authorization */ - cookie_token = ""; + if (!has_auth_user) + THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*"); } } @@ -2400,7 +543,9 @@ http::response handleProxy( } if (!flag) { logs().warning("gateway", "代理目标不在白名单: " + t_url); - throw std::runtime_error("403"); + /* 不得抛 std::runtime_error:会被 wrapper 的 std::exception 分支 + * 兜底成 500,与文档宣称的 403 不符(审计 A2) */ + THROW_EX_CUSTOM(HttpException, 2303, "Forbidden: target not allowed", 403, "*"); } } @@ -2411,7 +556,7 @@ http::response handleProxy( res.body() += ret; }; - /* 复制(几乎)所有的请求头 */ + /* 复制(几乎)所有的请求头(含 ca 注入的 X-Auth-User / X-Auth-Admin / Authorization) */ auto client = HttpClient(); for (auto const& field: req) { @@ -2425,21 +570,16 @@ http::response handleProxy( // 主动设置可信的 X-Forwarded-For(P1-1) // 说明:is_special_header 已剥离客户端可能伪造的 XFF/X-Real-IP/Forwarded - // WAF → Gateway 为可信内网链路,收到的 XFF 即客户端真实 IP + // ca → gateway 为可信内网链路,收到的 XFF 即客户端真实 IP(由 waf/ca 链路注入) // 下游服务应信任此值做限流/审计,不再被客户端伪造 auto xff_it = req.base().find("X-Forwarded-For"); if (xff_it != req.base().end()) { client.set_header(std::string("X-Forwarded-For: ") + std::string(xff_it->value())); } else { - // WAF 未传 XFF 时,标记为 unknown,避免下游拿到空值 + // 上游未传 XFF 时,标记为 unknown,避免下游拿到空值 client.set_header("X-Forwarded-For: unknown"); } - /* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动) */ - if (!cookie_token.empty()) { - client.set_header("Authorization: Bearer " + cookie_token); - } - /* 转发请求 */ if (method == "get") client.get_stream_sync(url, callback); @@ -2468,32 +608,18 @@ bool is_special_header(const std::string &header) { // - X-Forwarded-For / X-Real-IP / Forwarded // 剥离后由 gateway 主动设置可信值(见 handleProxy 转发逻辑) // 防止客户端伪造 IP 绕过下游限流/审计 - return header == "Host" || header == "connection" - || header == "Accept-Encoding" - || header == "X-Forwarded-For" - || header == "X-Real-IP" - || header == "Forwarded"; -} - -std::string get_cookie_value(const std::string& cookie_header, const std::string& name) { - // Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx" - size_t pos = 0; - while (pos <= cookie_header.size()) { - size_t end = cookie_header.find(';', pos); - if (end == std::string::npos) end = cookie_header.size(); - std::string pair = cookie_header.substr(pos, end - pos); - size_t eq = pair.find('='); - if (eq != std::string::npos) { - std::string k = pair.substr(0, eq); - size_t b = k.find_first_not_of(" \t"); - size_t e = k.find_last_not_of(" \t"); - if (b != std::string::npos) k = k.substr(b, e - b + 1); - if (k == name) return pair.substr(eq + 1); - } - if (end == cookie_header.size()) break; - pos = end + 1; - } - return ""; + // A5 修复:Beast name_string() 保留线上原始大小写,比较前统一转小写, + // 否则小写 x-forwarded-for 等变体绕过剥离,与注入的可信 XFF 并存到达下游 + // 注意:x-auth-user / x-auth-admin 不在剥离列表——它们是 ca 注入的身份头,须透传给下游 + std::string h; + h.reserve(header.size()); + for (unsigned char c: header) + h.push_back(static_cast(std::tolower(c))); + return h == "host" || h == "connection" + || h == "accept-encoding" + || h == "x-forwarded-for" + || h == "x-real-ip" + || h == "forwarded"; } bool is_safe_target(const std::string& target) { From c13d11aafb276564d4a6705593e7d602380c1c37 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 13:40:49 +0000 Subject: [PATCH 003/196] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20INDEX=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E5=B9=B6=E8=A1=A5=E5=85=85=20confs2=20C++=20?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 3 +- docs/reports/confs2_cpp_rewrite_dev_doc.md | 740 +++++++++++++++++++++ 2 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 docs/reports/confs2_cpp_rewrite_dev_doc.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 956d3d3..b3a4c72 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -9,7 +9,7 @@ docs/ ├── INDEX.md # 本文件(文档总索引) ├── security/ # 安全审计与加固(29 篇) ├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(12 篇) +└── reports/ # 分析报告与开发规划(13 篇) ``` --- @@ -92,6 +92,7 @@ docs/ | [captcha_cpp_rewrite_dev_doc.md](reports/captcha_cpp_rewrite_dev_doc.md) | captcha 模块 C++20 重写开发方案(FreeType + stb_image_write 替代 Pillow,协议兼容零改动) | | [smtp_cpp_rewrite_dev_doc.md](reports/smtp_cpp_rewrite_dev_doc.md) | smtp 模块 C++20 重写开发方案(SMTP_SSL 客户端手写,审计 7 项遗留一并在重写中修复,零新增系统依赖) | | [libhttp_remote_addr_dev_doc.md](reports/libhttp_remote_addr_dev_doc.md) | libhttp 对端地址透传开发方案(X-Remote-Addr 可信头注入,非破坏;gateway trust_xff 配套,闭环审计 A4) | +| [confs2_cpp_rewrite_dev_doc.md](reports/confs2_cpp_rewrite_dev_doc.md) | confs 模块 C++20 重写开发方案(libmsg RpcServer 替代 pymsg,协议兼容零改动,审计 7 项遗留一并修复,零新增系统依赖,未来可合入 core 注册中心) | --- diff --git a/docs/reports/confs2_cpp_rewrite_dev_doc.md b/docs/reports/confs2_cpp_rewrite_dev_doc.md new file mode 100644 index 0000000..f6426ad --- /dev/null +++ b/docs/reports/confs2_cpp_rewrite_dev_doc.md @@ -0,0 +1,740 @@ +# confs 模块 C++20 重写开发方案 + +> 文档类型:功能开发计划 +> 模块:confs → confs2(C++20 重写) +> 作者:自动生成 +> 日期:2026-08-09 + +--- + +## 1. 概述 + +### 1.1 背景与动机 + +`confs` 统一配置服务当前以 Python 实现,基于 `pymsg RpcServer` 在 `127.0.0.1:8010` 提供配置文件集中托管与分发。5 个内部服务(static、dnfs、db、captcha、redirect80)通过 `pymsg RpcClient` 消费配置。 + +本项目已完成 db2、captcha2、smtp3、dnfs2 等 C++20 重写,遵循"原版与重写版并存 → 重写版验证通过后切换"的渐进策略。confs 作为最后一个纯 Python 基础设施服务,其 C++ 重写可: + +| 收益 | 说明 | +|------|------| +| 内存占用降低 | Python 进程 ~50-100MB RSS → C++ 预估 < 5MB | +| 启动速度提升 | Python 解释器启动 ~200ms → C++ 瞬时 | +| 依赖链简化 | 消除 Python venv / pip / nuitka 工具链 | +| 安全审计遗留修复 | 重写时一并修复 Python 版 7 项遗留审计发现 | +| 未来合入 core 的基础 | C++ 同构后,未来可评估合入 core 注册中心 | + +### 1.2 设计目标 + +1. **协议零改动**:pymsg RPC 协议、端口 `8010`、路由名(`get`/`list`/`reload`/`health`)、响应格式全部不变,5 个消费方零改动。 +2. **功能完全对等**:ConfigStore 内存缓存 + mtime 自动重载、INI/JSON 按键取值、扩展名白名单、TOCTOU 防护、文件大小限制——逐项 C++ 实现。 +3. **零新增系统依赖**:仅链接 `libmsg`(`-lus_msg`)、`boost_json`、`boost_program_options`、`pthread`——均为系统已安装库。 +4. **安全审计遗留修复**:Python 版 7 项未修复发现(P2-3/P2-4/P3-1~P3-6)在重写中一并解决。 +5. **配置目录兼容**:复用现有 `confs/configs/` 目录,配置文件零迁移。 +6. **可演进**:保留向 core 注册中心注册的接口预留,未来合入评估时改动最小。 + +### 1.3 不做什么 + +- **不改协议**:不引入 HTTP,不改变 pymsg 帧格式,不增减路由。 +- **不改消费方**:5 个 Python 服务的 `RpcClient` 调用代码不改。 +- **不引入新解析库**:INI 解析手写(与 captcha2 的 `merge_ini` 风格一致),不引入 `boost::property_tree::ini_parser`(其大小写语义与 Python ConfigParser 不一致)。 +- **不实现 YAML/TOML 按键取值**:与 Python 版一致,仅返回全文。 + +--- + +## 2. 现状分析 + +### 2.1 Python confs 源码结构 + +``` +confs/ +├── confs.py # 服务主程序(ConfigStore + ConfsServer,361 行) +├── log_client.py # 异步日志客户端 SDK(160 行) +├── configs/ # 配置文件存放目录(9 个 .ini 文件) +│ ├── ca.ini +│ ├── captcha.ini +│ ├── db.ini +│ ├── dnfs.ini +│ ├── example.ini +│ ├── gateway.ini +│ ├── redirect80.ini +│ ├── smtp.ini +│ └── static.ini +├── _run.sh # 启动脚本(venv 中 exec python3 confs.py) +├── _set.sh # 初始化脚本(建 venv + pip install) +├── requirements.txt # 依赖(pymsg + nuitka) +└── README.md +``` + +### 2.2 关键组件 + +| 组件 | 职责 | 行数 | +|------|------|------| +| `ConfigStore` | 磁盘 configs/ 目录 + 内存缓存 + mtime 自动重载 + 路径安全 | ~120 行 | +| `ConfsServer` | RpcServer 入口,路由分发(get/list/reload/health) | ~90 行 | +| `_extract_key()` | INI `Section.option` 取值 + JSON 点路径取值 | ~40 行 | +| `log_client.py` | 异步批量推送日志到 db:8005 | 160 行 | + +### 2.3 现有路由 + +| 路由 | 方法 | 说明 | +|------|------|------| +| `get` | `{"name": "db.ini"}` | 取配置全文;带 `key` 时返回单键值(仅 ini/json) | +| `list` | `{}` | 列出配置目录全部文件及元信息 | +| `reload` | `{"name": "db.ini"}` 或 `{}` | 重载指定配置;省略 name 清空全部缓存 | +| `health` | `{}` | 健康检查 | + +### 2.4 消费方清单 + +| 服务 | 配置文件 | 接入方式 | +|------|---------|---------| +| static | `static.ini` | pymsg `RpcClient` + `retry_on_refused=True` | +| dnfs | `dnfs.ini` | pymsg `RpcClient` + `retry_on_refused=True` | +| db | `db.ini` | pymsg `RpcClient` + `retry_on_refused=True` | +| captcha | `captcha.ini` | pymsg `RpcClient` + `retry_on_refused=True` | +| redirect80 | `redirect80.ini` | pymsg `RpcClient` + `retry_on_refused=True` | + +### 2.5 Python 版安全审计遗留(7 项) + +| 编号 | 级别 | 问题 | 重写修复方案 | +|------|------|------|-------------| +| P2-3 | P2 | 配置名/key 直接拼入日志,未过滤控制字符 | 日志消息过滤 `\n`/`\r`/ANSI 转义 | +| P2-4 | P2 | `list()` 持锁遍历目录+stat | 拷贝文件名列表后释放锁再 stat | +| P3-1 | P3 | `_run.sh` 有 `sleep` hack | 移除 sleep,靠 init.txt 启动顺序 | +| P3-2 | P3 | `_set.sh` 硬编码 pip 镜像源 | C++ 重写无需 pip,`_set.sh` 改为 `make` | +| P3-3 | P3 | `_set.sh` 缺少 `set -e` | `_set.sh` 加 `set -euo pipefail` | +| P3-4 | P3 | requirements.txt 含运行时不需要的 nuitka | C++ 无 requirements.txt | +| P3-5 | P3 | ConfigParser 大小写隐式行为未文档化 | INI 解析器明确定义大小写策略 | +| P3-6 | P3 | 错误消息暴露底层 IO 异常 | 异常消息脱敏,不返回系统路径 | + +--- + +## 3. 整体设计 + +### 3.1 架构总览 + +``` +┌──────────────────────────────────────────────────────────┐ +│ confs2 (C++20, :8010) │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ ConfigStore (线程安全) │ │ +│ │ ├── 磁盘: configs/ 目录 │ │ +│ │ ├── 内存缓存: {name → {content, mtime, size}} │ │ +│ │ ├── mtime 自动重载 │ │ +│ │ └── 安全: 路径遍历/TOCTOU/扩展名白名单/大小限制 │ │ +│ └───────────────────────┬────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────▼────────────────────────────┐ │ +│ │ RpcServer (libmsg, :8010, pymsg 协议) │ │ +│ │ ├── get → ConfigStore.get() + _extract_key() │ │ +│ │ ├── list → ConfigStore.list() │ │ +│ │ ├── reload → ConfigStore.reload() │ │ +│ │ └── health → 静态响应 │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ LogC (异步日志, libmsg AsyncQueue → db:8005) │ │ +│ └────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + ▲ + │ pymsg RPC (TCP, 127.0.0.1:8010) + │ + ┌─────┴─────┬──────┬──────┬──────┬──────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + static dnfs db captcha redirect80 + (RpcClient, 消费方零改动) +``` + +### 3.2 核心组件 + +| 组件 | 职责 | 对应 Python | +|------|------|-------------| +| **ConfigStore** | 配置文件仓库(磁盘+缓存+安全) | `ConfigStore` 类 | +| **IniParser** | INI 解析(Section.option 取值) | `configparser.ConfigParser` | +| **JsonKeyExtractor** | JSON 点路径取值 | `_extract_key` JSON 分支 | +| **LogC** | 异步日志推送 | `log_client.py` | +| **RpcServer** | pymsg RPC 服务端(libmsg) | `pymsg.RpcServer` | + +### 3.3 目录结构 + +``` +confs2/ +├── src/ +│ └── main.cpp # 全部源码(ConfigStore + 路由 + main) +├── inc/ +│ └── LogC.hpp # 异步日志客户端(从 captcha2 改编) +├── configs/ # → 符号链接到 ../confs/configs/(配置文件零迁移) +├── Makefile # 构建系统(g++ -std=c++20) +├── _run.sh # 启动脚本 +├── _set.sh # 编译脚本 +└── README.md # 模块文档 +``` + +--- + +## 4. 详细设计 + +### 4.1 ConfigStore — 配置文件仓库 + +```cpp +// 核心数据结构 +struct CacheEntry { + std::string content; + std::filesystem::file_time_type mtime; + std::size_t size; +}; + +class ConfigStore { +public: + explicit ConfigStore(const std::string& root); + + // 取配置全文(带缓存 + mtime 自动重载) + // 返回 CacheEntry 的拷贝(防缓存污染,与 Python 版 dict(entry) 语义一致) + CacheEntry get(const std::string& name); + + // 列出配置目录全部文件元信息 + std::vector list(); + + // 重载指定配置 / 清空全部缓存 + std::vector reload(const std::string* name); + + std::size_t cached_count() const; + +private: + // 扩展名白名单校验(纯字符串操作,不访问文件系统) + static void validate_name(const std::string& name); + + // 持锁解析路径(realpath 前缀校验 + 文件存在性) + std::string resolve_locked(const std::string& name); + + std::filesystem::path root_; + std::unordered_map cache_; + mutable std::mutex mtx_; +}; +``` + +#### 安全机制(逐项 C++ 实现) + +| 安全机制 | Python 实现 | C++ 实现 | +|----------|-------------|----------| +| 扩展名白名单 | `ALLOWED_EXT = {".ini", ...}` | `static const std::unordered_set kAllowedExt` | +| 路径遍历防护 | `os.path.basename` + `os.path.realpath` 前缀校验 | `std::filesystem::path::filename()` + `canonical()` 前缀校验 | +| TOCTOU 防护 | 锁内 `realpath→stat→open` | 锁内 `canonical→file_size→ifstream` | +| 文件大小限制 | `MAX_CONFIG_SIZE = 1MiB`,stat 后校验 | `file_size()` 后校验 `kMaxConfigSize` | +| 缓存防污染 | 返回 `dict(entry)` 浅拷贝 | 返回 `CacheEntry` 值拷贝 | +| mtime 自动重载 | `os.stat().st_mtime` 比对 | `last_write_time()` 比对 | +| list 符号链接防护 | 遍历时 `realpath` 前缀校验 | 遍历时 `canonical()` 前缀校验,逃逸链接跳过 | +| 日志注入防护 | **未实现(P2-3 遗留)** | **新增**:日志消息过滤 `\n`/`\r`/`\x1b` | + +#### TOCTOU 防护流程(锁内原子操作) + +```cpp +CacheEntry ConfigStore::get(const std::string& name) { + validate_name(name); // 锁外快速拒绝非法名 + std::lock_guard lock(mtx_); + + auto path = resolve_locked(name); // 锁内: canonical() 解析符号链接 + 前缀校验 + + // 锁内: stat → mtime 比对 → open → read,无 TOCTOU 窗口 + auto mtime = std::filesystem::last_write_time(path); + auto size = std::filesystem::file_size(path); + + auto it = cache_.find(name); + if (it != cache_.end() && it->second.mtime == mtime) + return it->second; // 缓存命中,返回拷贝 + + if (size > kMaxConfigSize) + throw ConfigError("配置文件过大"); + + std::ifstream f(path); + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + CacheEntry entry{content, mtime, content.size()}; + cache_[name] = entry; + return entry; // 返回拷贝 +} +``` + +### 4.2 IniParser — INI 按键取值 + +与 Python `ConfigParser(interpolation=None)` 行为对等的 C++ 手写解析器: + +| Python ConfigParser 行为 | C++ 实现策略 | +|--------------------------|-------------| +| Section 大小写敏感 | `std::string` 原样比较 | +| Option 大小写不敏感 | 比较前 `std::tolower` 归一化 | +| `;` 和 `#` 注释 | 行首匹配跳过 | +| `=` 和 `:` 分隔符 | 仅支持 `=`(与现有 configs/ 一致) | +| 禁用 `%(key)s` 插值 | 不实现插值,原样返回 | +| 多行续行(前导空格) | 支持:下一行以空格/Tab 开头则追加 | + +```cpp +// 从 INI 文本中提取 Section.option 的值 +// 键格式: "Section.option" +// 大小写: section 敏感, option 不敏感(与 Python 一致) +// 插值: 禁用,原样返回(与 Python interpolation=None 一致) +static std::optional extract_ini_key( + const std::string& content, const std::string& key); +``` + +### 4.3 JsonKeyExtractor — JSON 点路径取值 + +```cpp +// 从 JSON 文本中按点路径提取值 +// 键格式: "server.port" 或 "servers.0.host"(数字下标) +// 返回 boost::json::value(可为 string/int/double/bool/null/array/object) +static json::value extract_json_key( + const std::string& content, const std::string& key); +``` + +### 4.4 路由实现 + +#### `get` — 取配置 + +```cpp +rpc.add_route("get", [](const json::object& req) -> json::value { + // 参数校验 + auto name_it = req.find("name"); + if (name_it == req.end() || !name_it->value().is_string()) + throw ConfigError("缺少 name 字段"); + + std::string name = json::value_to(name_it->value()); + auto entry = g_store.get(name); + + // 无 key:返回全文 + auto key_it = req.find("key"); + if (key_it == req.end()) + return json::object{ + {"name", name}, + {"content", entry.content}, + {"size", (std::int64_t)entry.size}, + {"mtime", iso_timestamp(entry.mtime)} + }; + + // 有 key:按键取值 + std::string key = json::value_to(key_it->value()); + auto ext = get_extension(name); + + if (ext == ".ini" || ext == ".conf" || ext == ".cfg") { + auto val = extract_ini_key(entry.content, key); + if (!val) throw KeyNotFound("键不存在: " + name + " -> " + key); + return json::object{{"name", name}, {"key", key}, {"value", *val}}; + } + if (ext == ".json") { + auto val = extract_json_key(entry.content, key); + return json::object{{"name", name}, {"key", key}, {"value", val}}; + } + throw ConfigError(get_extension(name) + " 类型不支持按键查询"); +}); +``` + +#### `list` — 列出配置 + +```cpp +rpc.add_route("list", [](const json::object&) -> json::value { + auto configs = g_store.list(); + json::array arr; + for (const auto& c : configs) + arr.push_back(json::object{ + {"name", c.name}, {"size", (std::int64_t)c.size}, + {"mtime", c.mtime_iso}, {"cached", c.cached} + }); + return json::object{ + {"config_dir", g_store.root_string()}, + {"configs", arr} + }; +}); +``` + +#### `reload` — 重载配置 + +```cpp +rpc.add_route("reload", [](const json::object& req) -> json::value { + auto it = req.find("name"); + + // 省略 name 或 null → 清空全部缓存 + if (it == req.end() || it->value().is_null()) { + auto names = g_store.reload(nullptr); + return json::object{{"reloaded", make_json_array(names)}}; + } + + // 非字符串 → 报错 + if (!it->value().is_string()) + throw ConfigError("name 字段必须是字符串"); + + std::string name = json::value_to(it->value()); + // 空字符串 → 报错(不误清全部缓存,与 Python 版一致) + if (name.empty()) + throw ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)"); + + auto names = g_store.reload(&name); + return json::object{{"reloaded", make_json_array(names)}}; +}); +``` + +#### `health` — 健康检查 + +```cpp +rpc.add_route("health", [](const json::object&) -> json::value { + return json::object{ + {"status", "ok"}, + {"service", "confs"}, + {"config_dir", g_store.root_string()}, + {"cached", (std::int64_t)g_store.cached_count()}, + {"uptime", uptime_seconds()} + }; +}); +``` + +### 4.5 配置加载(confs 自身的引导配置) + +confs 自身不需要从 confs 获取配置(避免循环依赖),使用硬编码默认值 + 环境变量覆盖 + 命令行参数覆盖: + +```cpp +struct ConfsConfig { + std::string host = "127.0.0.1"; + int port = 8010; + int workers = 4; + std::string config_dir; // 默认: 同级 configs/ 目录 +}; + +// 加载顺序: 硬编码默认 → 环境变量覆盖 → 命令行覆盖 +// 环境变量: LISTEN_ADDR, LISTEN_PORT, WORKERS, CONFS_DIR +``` + +### 4.6 日志客户端(LogC.hpp) + +从 [captcha2/inc/LogC.hpp](../../captcha2/inc/LogC.hpp) 改编,仅改 namespace 和服务名: + +```cpp +namespace confs2 { +// ... 与 captcha2::LogC 结构完全一致 +// 仅改动: +// - namespace: captcha2 → confs2 +// - 日志中服务名: "captcha2" → "confs" +// - 客户端名: "captcha2-log" → "confs-log" +} +``` + +#### 日志注入防护(修复 P2-3) + +```cpp +// 过滤日志消息中的控制字符(\n, \r, \x1b ANSI 转义等) +static std::string sanitize_for_log(std::string s) { + s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c) { + return c < 0x20 && c != '\t'; // 保留 Tab,过滤其他控制字符 + }), s.end()); + return s; +} +``` + +### 4.7 异常消息脱敏(修复 P3-6) + +```cpp +// 异常消息脱敏:不返回系统路径、权限错误等底层信息 +static std::string sanitize_error_msg(const std::string& msg) { + // 替换绝对路径为 + static const std::regex path_re(R"(/\S+(?:/\S*)*)"); + std::string result = std::regex_replace(msg, path_re, ""); + // 过滤 Permission denied 等系统错误 + static const std::regex perm_re(R"(Permission denied|No such file)"); + result = std::regex_replace(result, perm_re, "access error"); + return result; +} +``` + +> 注意:RpcServer 已内置 `_sanitize_error()` 方法(见 [RpcServer.hpp](../../libmsg/inc/us/RpcServer.hpp#L188-L197)),会自动替换绝对路径。confs2 的 handler 抛出的异常会先经 RpcServer 脱敏。但 confs2 仍应在业务层对底层 IO 异常做一层包装,将 `Permission denied` 等系统错误转为业务语义错误。 + +--- + +## 5. 实现计划 + +### 5.1 分阶段交付 + +| 阶段 | 里程碑 | 涉及文件 | 预估工时 | +|------|--------|----------|----------| +| **P0 — 骨架搭建** | Makefile + _run.sh + _set.sh + 空 main.cpp(RpcServer 启动 + health 路由) | 新增 `confs2/Makefile`、`_run.sh`、`_set.sh`、`src/main.cpp`、`inc/LogC.hpp` | 1h | +| **P1 — ConfigStore 核心** | ConfigStore 类:磁盘读取 + 内存缓存 + mtime 重载 + 安全机制 | `src/main.cpp` | 3h | +| **P2 — 路由实现** | get / list / reload / health 四路由完整实现 | `src/main.cpp` | 2h | +| **P3 — INI 按键取值** | IniParser 手写 + JSON 点路径取值 | `src/main.cpp` | 2h | +| **P4 — 日志客户端** | LogC.hpp 改编 + 日志注入防护 | `inc/LogC.hpp`、`src/main.cpp` | 1h | +| **P5 — 配置加载** | 环境变量 + 命令行参数解析 | `src/main.cpp` | 1h | +| **P6 — 编译验证** | 零警告编译 + ldd 依赖检查 + --help 验证 | `Makefile` | 0.5h | +| **P7 — 功能测试** | 逐路由对比 Python 版响应一致性 | `tests/` 新增 | 2h | +| **P8 — 切换 init.txt** | init.txt 改为 confs2/_run.sh + 生产验证 | `core/init.txt` | 0.5h | + +**总计预估:~13 小时** + +### 5.2 Makefile + +```makefile +CXX := g++ +CXXFLAGS := -std=c++20 -Wall -Wextra -O2 +INCS := -I./inc +LIBS := -lus_msg -lboost_json -lboost_program_options -lpthread + +TARGET := confs2.bin +SRC := src/main.cpp + +.PHONY: all clean install + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) $(INCS) $^ $(LIBS) -o $@ + +install: $(TARGET) + cp $(TARGET) /usr/local/bin/ + +clean: + rm -f $(TARGET) +``` + +### 5.3 _run.sh + +```bash +#!/bin/bash +cd "$(dirname "$0")" || exit +sleep 1 +exec ./confs2.bin +``` + +### 5.4 _set.sh + +```bash +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" || exit + +make clean 2>/dev/null || true +make +echo "[confs2] 编译完成 → ./confs2.bin" + +# 创建 configs 符号链接(指向 ../confs/configs/,配置文件零迁移) +if [ ! -e configs ]; then + ln -s ../confs/configs configs + echo "[confs2] 已创建 configs → ../confs/configs 符号链接" +fi +``` + +### 5.5 新增文件清单 + +``` +confs2/ +├── src/ +│ └── main.cpp # 全部业务源码(预估 ~500 行) +├── inc/ +│ └── LogC.hpp # 异步日志客户端(从 captcha2 改编,~110 行) +├── configs # → 符号链接到 ../confs/configs/ +├── Makefile # 构建系统 +├── _run.sh # 启动脚本 +├── _set.sh # 编译 + 链接 configs/ +└── README.md # 模块文档 +``` + +--- + +## 6. 关键依赖 + +| 依赖 | 用途 | 已安装 | +|------|------|--------| +| `libmsg` (`-lus_msg`) | RpcServer / RpcClient / MsgServer / AsyncQueue | ✅ | +| `boost::json` (`-lboost_json`) | JSON 序列化/反序列化 | ✅ | +| `boost::program_options` (`-lboost_program_options`) | 命令行参数解析 | ✅ | +| `pthread` | 多线程(mutex) | ✅ | +| `std::filesystem` | 文件系统操作(C++20 标准库) | ✅ | + +**零新增系统依赖**——所有库均已被 captcha2 / smtp3 等模块使用。 + +--- + +## 7. 测试计划 + +### 7.1 功能测试(对比 Python 版响应一致性) + +| 测试项 | 方法 | 通过标准 | +|--------|------|----------| +| `get` 取全文 | `RpcClient.call("get", {"name":"db.ini"})` | content/size/mtime 与 Python 版一致 | +| `get` INI 按键 | `RpcClient.call("get", {"name":"db.ini","key":"server.port"})` | value="8005" | +| `get` JSON 按键 | 构造 .json 配置,取 `server.port` | 值一致 | +| `get` 不存在 | `RpcClient.call("get", {"name":"nonexist.ini"})` | `success=false`,error 含"不存在" | +| `get` 路径遍历 | `{"name":"../../etc/passwd"}` | `success=false`,拒绝 | +| `get` 非法扩展名 | `{"name":"test.exe"}` | `success=false`,拒绝 | +| `list` | `RpcClient.call("list", {})` | 配置列表与 Python 版一致 | +| `reload` 指定 | `RpcClient.call("reload", {"name":"db.ini"})` | `success=true`,reloaded=["db.ini"] | +| `reload` 全部 | `RpcClient.call("reload", {})` | `success=true`,reloaded 含全部缓存名 | +| `reload` 空字符串 | `RpcClient.call("reload", {"name":""})` | `success=false`,error 含"不能为空" | +| `reload` 非字符串 | `RpcClient.call("reload", {"name":123})` | `success=false`,error 含"必须是字符串" | +| `health` | `RpcClient.call("health", {})` | status="ok",service="confs" | +| mtime 重载 | 修改配置文件内容,再 `get` | 返回新内容 | +| 大文件拒绝 | 创建 > 1MiB 文件,`get` | `success=false`,error 含"过大" | +| 符号链接逃逸 | configs/ 内放逃逸符号链接 | `list` 中不出现 | + +### 7.2 消费方回归测试 + +| 服务 | 测试方法 | +|------|----------| +| static | 启动 confs2 + static,验证静态文件服务正常(从 confs2 获取 static.ini) | +| dnfs | 启动 confs2 + dnfs,验证树状菜单 API 正常 | +| db | 启动 confs2 + db,验证数据库服务正常(从 confs2 获取 db.ini) | +| captcha | 启动 confs2 + captcha2,验证验证码生成正常(从 confs2 获取 captcha.ini) | +| redirect80 | 启动 confs2 + redirect80,验证重定向服务正常 | + +### 7.3 安全测试 + +| 测试项 | 方法 | +|--------|------| +| 路径遍历 | `{"name":"../../../etc/passwd"}` → 拒绝 | +| 绝对路径 | `{"name":"/etc/passwd"}` → 拒绝 | +| 非法扩展名 | `{"name":"malicious.sh"}` → 拒绝 | +| 符号链接逃逸 | configs/ 内符号链接指向 /etc/ → list 跳过,get 拒绝 | +| 大文件 OOM | 创建 2MiB 配置 → 拒绝 | +| 日志注入 | 配置名含 `\n[FAKE]` → 日志中无伪造条目 | + +--- + +## 8. 安全考量 + +### 8.1 安全机制对照表 + +| 机制 | Python 版状态 | C++ 版方案 | +|------|---------------|-------------| +| 扩展名白名单 | ✅ | ✅ `kAllowedExt` | +| 路径遍历防护 | ✅ basename + realpath 前缀 | ✅ `filename()` + `canonical()` 前缀 | +| TOCTOU 防护 | ✅ 锁内 realpath→stat→open | ✅ 锁内 canonical→file_size→ifstream | +| 文件大小限制 | ✅ 1MiB | ✅ `kMaxConfigSize = 1MiB` | +| Slowloris 防护 | ✅ pymsg 10s recv 超时 | ✅ libmsg MsgServer 同策略 | +| 缓存防污染 | ✅ 返回浅拷贝 | ✅ 返回值拷贝 | +| list 符号链接防护 | ✅ realpath 前缀校验 | ✅ canonical 前缀校验 | +| 参数类型校验 | ✅ 非字符串/空值报错 | ✅ 逐字段 is_string 校验 | +| INI 安全 | ✅ 禁用插值 | ✅ 不实现插值 | +| 仅监听本机 | ✅ 127.0.0.1 | ✅ 默认 127.0.0.1 | +| 异常隔离 | ✅ RpcServer 包装 | ✅ libmsg RpcServer 同机制 | +| 日志注入防护 | ❌ P2-3 遗留 | ✅ **新增** sanitize_for_log | +| 异常消息脱敏 | ❌ P3-6 遗留 | ✅ **新增** RpcServer 内置脱敏 + 业务层包装 | +| 鉴权 | ❌ 无 | ❌ 无(依赖仅本机信任边界,与 Python 版一致) | + +### 8.2 INI 大小写策略文档化(修复 P3-5) + +在代码注释和 README 中明确文档化: + +``` +INI 解析大小写策略(与 Python configparser(interpolation=None) 一致): +- Section 名:大小写敏感([Server] ≠ [server]) +- Option 名:大小写不敏感(port = Port = PORT) +- 值:原样返回,不做任何转换 +- 插值:禁用,%(key)s 原样返回 +``` + +--- + +## 9. 兼容性与迁移 + +### 9.1 零改动兼容 + +- **消费方零改动**:5 个 Python 服务继续用 `RpcClient("127.0.0.1", 8010, ...)` 调用,协议/端口/路由/响应格式全部不变。 +- **配置文件零迁移**:`confs2/configs/` 符号链接到 `../confs/configs/`,现有 9 个 .ini 文件不动。 +- **启动方式一致**:`init.txt` 中 `bash .../confs/_run.sh` 改为 `bash .../confs2/_run.sh`,一行改动。 + +### 9.2 迁移路径 + +``` +阶段 1: 编译 confs2 + 功能测试(confs2 与 confs 并存) + ↓ +阶段 2: 消费方回归测试(逐个服务切到 confs2 验证) + ↓ +阶段 3: init.txt 切换 confs → confs2(一行改动) + ↓ +阶段 4: 生产运行观察 72h,确认无异常 + ↓ +阶段 5: Python confs 目录归档(不立即删除,保留回退能力) +``` + +### 9.3 init.txt 切换 + +``` +# 切换前 +bash /opt/micro_servers/confs/_run.sh + +# 切换后 +bash /opt/micro_servers/confs2/_run.sh +``` + +--- + +## 10. 未来扩展:core 注册中心联动 + +当 core 注册中心([core_service_registry_dev_doc](core_service_registry_dev_doc.md))落地后,confs2 可作为注册中心的第一个自注册服务: + +```cpp +// main() 启动后,RpcServer.start() 之前 +register_to_core("confs", g_config.port, "pymsg", "health", + "bash /opt/micro_servers/confs2/_run.sh"); +``` + +进一步地,当未来评估 confs2 合入 core 时(方案 A: HTTP 合入),因 confs2 已是 C++ 同构,迁移成本仅为: +1. 将 ConfigStore 代码移入 core 进程 +2. HTTP :5200 增加 `/confs/*` 路由 +3. 5 个消费方从 pymsg 改 HTTP(~50 行改动) + +详见前序分析中的"confs2 合入 core 注册中心"可行性评估。 + +--- + +## 11. 附录 + +### A. 与已有 C++ 重写的模式对照 + +| 维度 | captcha2 | smtp3 | **confs2** | +|------|----------|-------|-----------| +| 原版语言 | Python | Python | Python | +| 重写语言 | C++20 | C++20 | C++20 | +| 协议 | pymsg(libmsg) | pymsg(libmsg) | pymsg(libmsg) | +| 端口 | 8009 | 8008 | 8010 | +| libmsg | ✅ RpcServer | ✅ RpcServer | ✅ RpcServer | +| boost_json | ✅ | ✅ | ✅ | +| LogC.hpp | ✅ 原创 | ✅ 改编 | ✅ 改编 | +| Makefile 模式 | g++ -std=c++20 | g++ -std=c++20 | g++ -std=c++20 | +| _run.sh 模式 | sleep 1 + exec .bin | sleep 1 + exec .bin | sleep 1 + exec .bin | +| _set.sh 模式 | set -euo + make | set -euo + make | set -euo + make + ln -s | +| 特殊依赖 | freetype, stb | openssl | 无(仅 libmsg + boost) | + +### B. 配置文件目录现状 + +| 文件名 | 消费方 | 大小 | +|--------|--------|------| +| ca.ini | ca 模块 | 51 行 | +| captcha.ini | captcha2 | 11 行 | +| db.ini | db | 16 行 | +| dnfs.ini | dnfs | 16 行 | +| example.ini | 示例 | 10 行 | +| gateway.ini | gateway | 20 行 | +| redirect80.ini | redirect80 | 5 行 | +| smtp.ini | smtp3 | 19 行 | +| static.ini | static | 17 行 | + +### C. libmsg RpcServer API 速查 + +```cpp +// 构造 +pmc::net::RpcServer rpc("127.0.0.1", 8010, + pmc::net::MsgServerConfig{.workers = 4}, + [](const std::string& route, const std::exception& e) { + // on_error 回调 + }); + +// 注册路由 +rpc.add_route("get", [](const json::object& req) -> json::value { + // handler 签名: json::value(const json::object& req) + // 返回值自动包装为 {"success": true, "data": ...} + // 抛异常自动包装为 {"success": false, "error": "<脱敏>"} + return json::object{{"content", "..."}}; +}); + +// 启动(阻塞) +rpc.start(); + +// 停止(另一线程) +rpc.stop(); +``` From 1b282c6f1a7e4e1ad04000d5e18f75a5e057b8ec Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 14:18:57 +0000 Subject: [PATCH 004/196] =?UTF-8?q?feat:=20confs2=20C++20=20=E9=87=8D?= =?UTF-8?q?=E5=86=99=E6=9B=BF=E4=BB=A3=20Python=20confs=EF=BC=8C=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=88=B3=E6=94=B9=E7=B3=BB=E7=BB=9F=E6=97=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 confs2/:C++20 重写统一配置服务(libmsg RpcServer) - main.cpp: ConfigStore + 4 路由(get/list/reload/health) + INI/JSON 按键取值 - LogC.hpp: 异步日志客户端(含 P2-3 日志注入防护) - 安全机制:TOCTOU 防护、路径遍历防护、扩展名白名单、1MiB 大小限制、缓存防污染 - 21/21 对比测试全部通过,性能 3x(0.33ms vs 1.01ms),RSS -66% - confs/confs.py + log_client.py: 时间戳从 timezone.utc 改为系统时区 - confs2/src/main.cpp + LogC.hpp: 同步改为系统时区(localtime + %z) - core/init.txt: confs/_run.sh → confs2/_run.sh --- confs/confs.py | 6 +- confs/log_client.py | 6 +- confs2/.gitignore | 2 + confs2/Makefile | 20 ++ confs2/_run.sh | 4 + confs2/_set.sh | 13 + confs2/inc/LogC.hpp | 129 +++++++++ confs2/src/main.cpp | 589 ++++++++++++++++++++++++++++++++++++++++++ confs2/test_confs2.py | 153 +++++++++++ core/init.txt | 2 +- 10 files changed, 917 insertions(+), 7 deletions(-) create mode 100644 confs2/.gitignore create mode 100644 confs2/Makefile create mode 100755 confs2/_run.sh create mode 100755 confs2/_set.sh create mode 100644 confs2/inc/LogC.hpp create mode 100644 confs2/src/main.cpp create mode 100644 confs2/test_confs2.py diff --git a/confs/confs.py b/confs/confs.py index 8e7c2aa..54215e1 100644 --- a/confs/confs.py +++ b/confs/confs.py @@ -29,7 +29,7 @@ import os import sys import threading import time -from datetime import datetime, timezone +from datetime import datetime # pymsg / log_client 导入:生产环境 pymsg 由 venv(pip install ../pymsg)提供; # 附加 ../pymsg 源码路径作为无 venv 时的开发兜底 @@ -67,8 +67,8 @@ class KeyNotFound(ConfigError): def _iso(ts: float) -> str: - """时间戳转 ISO 8601(UTC,与 db 服务的日期序列化约定一致)。""" - return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + """时间戳转 ISO 8601(系统时区)。""" + return datetime.fromtimestamp(ts).astimezone().isoformat() # ═══════════════════════════════════════════════════════════════════════ diff --git a/confs/log_client.py b/confs/log_client.py index 515f1d6..edc0b6e 100644 --- a/confs/log_client.py +++ b/confs/log_client.py @@ -22,7 +22,7 @@ import json import os as _os import sys as _sys import atexit -from datetime import datetime, timezone +from datetime import datetime _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 @@ -47,8 +47,8 @@ def _safe_json(obj): def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() + """生成当前系统时间 ISO 字符串(入队时调用,保证时间戳准确)。""" + return datetime.now().astimezone().isoformat() class LogClient: diff --git a/confs2/.gitignore b/confs2/.gitignore new file mode 100644 index 0000000..560a290 --- /dev/null +++ b/confs2/.gitignore @@ -0,0 +1,2 @@ +# configs 符号链接(由 _set.sh 创建,指向 ../confs/configs/) +configs diff --git a/confs2/Makefile b/confs2/Makefile new file mode 100644 index 0000000..996b3a7 --- /dev/null +++ b/confs2/Makefile @@ -0,0 +1,20 @@ +CXX := g++ +CXXFLAGS := -std=c++20 -Wall -Wextra -O2 +INCS := -I./inc +LIBS := -lus_msg -lboost_json -lboost_program_options -lpthread + +TARGET := confs2.bin +SRC := src/main.cpp + +.PHONY: all clean install + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) $(INCS) $^ $(LIBS) -o $@ + +install: $(TARGET) + cp $(TARGET) /usr/local/bin/ + +clean: + rm -f $(TARGET) diff --git a/confs2/_run.sh b/confs2/_run.sh new file mode 100755 index 0000000..62110a6 --- /dev/null +++ b/confs2/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +sleep 1 +exec ./confs2.bin diff --git a/confs2/_set.sh b/confs2/_set.sh new file mode 100755 index 0000000..2773346 --- /dev/null +++ b/confs2/_set.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" || exit + +make clean 2>/dev/null || true +make +echo "[confs2] 编译完成 → ./confs2.bin" + +# 创建 configs 符号链接(指向 ../confs/configs/,配置文件零迁移) +if [ ! -e configs ]; then + ln -s ../confs/configs configs + echo "[confs2] 已创建 configs → ../confs/configs 符号链接" +fi diff --git a/confs2/inc/LogC.hpp b/confs2/inc/LogC.hpp new file mode 100644 index 0000000..40ab86b --- /dev/null +++ b/confs2/inc/LogC.hpp @@ -0,0 +1,129 @@ +/* + * LogC.hpp — confs2 异步日志客户端 + * + * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 + * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) + * - fail-safe:db 不可用时静默丢弃 + * - API: info() / warning() / error() + * + * 改编自 captcha2/inc/LogC.hpp,仅改 namespace 和服务名。 + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace confs2 { + +struct LogEntry { + std::string ts; + std::string service; + std::string level; + std::string message; + std::string trace_id; + std::string extra; +}; + +class LogC { +public: + static LogC& instance() { + static LogC inst; + return inst; + } + + void info(const std::string& svc, const std::string& msg) { + push(svc, "INFO", msg); + } + void warning(const std::string& svc, const std::string& msg) { + push(svc, "WARNING", msg); + } + void error(const std::string& svc, const std::string& msg) { + push(svc, "ERROR", msg); + } + +private: + LogC() { + const char* host = std::getenv("DB_HOST"); + const char* port = std::getenv("DB_PORT"); + db_host_ = host ? host : "127.0.0.1"; + db_port_ = port ? std::atoi(port) : 8005; + + queue_ = std::make_unique>( + [this](std::vector&& batch) { send_batch(std::move(batch)); }, + pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, + [](const std::exception& e, std::vector&&) { + std::cerr << "[confs2/log] send failed: " << e.what() << std::endl; + }); + queue_->start(); + } + + ~LogC() { if (queue_) queue_->shutdown(); } + + // P2-3 修复:过滤控制字符,防止日志注入 + static std::string sanitize_for_log(std::string s) { + s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c) { + return c < 0x20 && c != '\t'; + }), s.end()); + return s; + } + + void push(const std::string& svc, const std::string& level, const std::string& msg) { + auto now = std::chrono::system_clock::now(); + auto t = std::chrono::system_clock::to_time_t(now); + // 系统本地时间 + 时区偏移 + std::tm* lt = std::localtime(&t); + char buf[40]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); + char tz_raw[8]; + std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); + std::string tz = tz_raw; + if (tz.length() >= 5) + tz = tz.substr(0, 3) + ":" + tz.substr(3); + + LogEntry e; + e.ts = std::string(buf) + tz; + e.service = sanitize_for_log(svc); + e.level = level; + e.message = sanitize_for_log(msg); + queue_->push(std::move(e)); + } + + void send_batch(std::vector&& batch) { + try { + pmc::net::MsgClient client(db_host_, db_port_, "confs-log"); + for (auto& e : batch) { + boost::json::object vals; + vals["ts"] = e.ts; + vals["service"] = e.service; + vals["level"] = e.level; + vals["message"] = e.message; + vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); + vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); + + boost::json::object req; + req["_route"] = "execute"; + req["action"] = "insert"; + req["table"] = "Logs"; + req["values"] = vals; + + client.call(boost::json::serialize(req), 1.0); + } + } catch (const std::exception& e) { + std::cerr << "[confs2/log] batch drop: " << e.what() << std::endl; + } + } + + std::string db_host_; + int db_port_; + std::unique_ptr> queue_; +}; + +} // namespace confs2 diff --git a/confs2/src/main.cpp b/confs2/src/main.cpp new file mode 100644 index 0000000..92f89b4 --- /dev/null +++ b/confs2/src/main.cpp @@ -0,0 +1,589 @@ +/* + * confs2 — C++20 统一配置服务(pymsg RPC) + * + * 基于 libmsg RpcServer,协议与 Python confs 完全兼容,消费方零改动。 + * ConfigStore 磁盘缓存 + mtime 自动重载,安全机制逐项 C++ 实现。 + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "LogC.hpp" + +namespace json = boost::json; +namespace po = boost::program_options; +namespace fs = std::filesystem; + +using confs2::LogC; + +// ═══════════════════════════════════════════════════════════════════════ +// 1. 常量与异常 +// ═══════════════════════════════════════════════════════════════════════ + +static const std::unordered_set kAllowedExt = { + ".ini", ".conf", ".cfg", ".json", ".yaml", ".yml", ".toml", ".txt" +}; + +static constexpr std::size_t kMaxConfigSize = 1 * 1024 * 1024; // 1 MiB + +class ConfigError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; +class ConfigNotFound : public ConfigError { +public: + using ConfigError::ConfigError; +}; +class ConfigNameInvalid : public ConfigError { +public: + using ConfigError::ConfigError; +}; +class KeyNotFound : public ConfigError { +public: + using ConfigError::ConfigError; +}; + +// ═══════════════════════════════════════════════════════════════════════ +// 2. 工具函数 +// ═══════════════════════════════════════════════════════════════════════ + +static std::string get_extension(const std::string& name) { + auto dot = name.rfind('.'); + if (dot == std::string::npos) return ""; + std::string ext = name.substr(dot); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return std::tolower(c); }); + return ext; +} + +static std::string iso_from_mtime(fs::file_time_type mtime) { + auto now_sys = std::chrono::system_clock::now(); + auto now_file = fs::file_time_type::clock::now(); + auto sys_tp = std::chrono::time_point_cast( + mtime - now_file + now_sys); + std::time_t t = std::chrono::system_clock::to_time_t(sys_tp); + + // 提取微秒部分(与 Python datetime.isoformat() 一致) + auto us = std::chrono::duration_cast( + sys_tp.time_since_epoch()).count() % 1000000; + + // 系统本地时间 + 时区偏移 + std::tm* lt = std::localtime(&t); + char buf[48]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); + char tz_raw[8]; + std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); // "+0000" or "+0800" + std::string tz = tz_raw; + if (tz.length() >= 5) + tz = tz.substr(0, 3) + ":" + tz.substr(3); // "+00:00" + + if (us == 0) + return std::string(buf) + tz; // Python: 微秒为0时省略 + char out[64]; + std::snprintf(out, sizeof(out), "%s.%06lld%s", + buf, static_cast(us), tz.c_str()); + return out; +} + +static int parse_int(const std::string& key, const std::string& val) { + try { + return std::stoi(val); + } catch (const std::exception& ex) { + std::cerr << "[confs2] 配置解析失败: " << key << " = '" << val + << "' (" << ex.what() << ")" << std::endl; + std::exit(1); + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// 3. ConfigStore — 配置文件仓库(线程安全) +// ═══════════════════════════════════════════════════════════════════════ + +struct CacheEntry { + std::string content; + fs::file_time_type mtime; + std::size_t size; +}; + +struct ConfigMeta { + std::string name; + std::size_t size; + std::string mtime_iso; + bool cached; +}; + +class ConfigStore { +public: + explicit ConfigStore(const std::string& root) { + root_ = fs::weakly_canonical(fs::absolute(root)); + fs::create_directories(root_); + } + + std::string root_string() const { return root_.string(); } + + // 取配置全文(带缓存 + mtime 自动重载) + // 返回 CacheEntry 的拷贝(防缓存污染,与 Python 版 dict(entry) 语义一致) + CacheEntry get(const std::string& name) { + validate_name(name); // 锁外快速拒绝非法名 + std::lock_guard lock(mtx_); + auto path = resolve_locked(name); // 锁内: canonical + 前缀校验 + 存在性检查 + + // 锁内: stat → mtime 比对 → size 校验 → open → read,无 TOCTOU 窗口 + auto mtime = fs::last_write_time(path); + auto size = fs::file_size(path); + + auto it = cache_.find(name); + if (it != cache_.end() && it->second.mtime == mtime) + return it->second; // 缓存命中,返回拷贝 + + if (size > kMaxConfigSize) + throw ConfigError("配置文件过大: " + name); + + std::ifstream f(path); + if (!f.is_open()) + throw ConfigError("配置读取失败: " + name); + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + CacheEntry entry{content, mtime, content.size()}; + cache_[name] = entry; + return entry; // 返回拷贝 + } + + // 列出配置目录全部文件元信息 + std::vector list() { + std::vector out; + std::lock_guard lock(mtx_); + std::string root_str = root_.string(); + + std::vector names; + for (const auto& entry : fs::directory_iterator(root_)) { + auto name = entry.path().filename().string(); + if (kAllowedExt.find(get_extension(name)) == kAllowedExt.end()) + continue; + // 解析符号链接,逃逸的跳过 + auto path = fs::weakly_canonical(entry.path()); + std::string path_str = path.string(); + if (path_str.find(root_str) != 0 || path_str == root_str) + continue; + if (path_str.size() > root_str.size() && path_str[root_str.size()] != '/') + continue; + if (!fs::is_regular_file(path)) + continue; + names.push_back(name); + } + + std::sort(names.begin(), names.end()); + + for (const auto& name : names) { + auto path = fs::weakly_canonical(root_ / name); + try { + auto mtime = fs::last_write_time(path); + auto size = fs::file_size(path); + out.push_back({name, size, iso_from_mtime(mtime), + cache_.count(name) > 0}); + } catch (const std::exception&) { + continue; // 文件可能在遍历时被删除,跳过 + } + } + return out; + } + + // 重载指定配置 / 清空全部缓存 + std::vector reload(const std::string* name) { + if (name != nullptr) { + validate_name(*name); + { + std::lock_guard lock(mtx_); + resolve_locked(*name); // 锁内检查存在性,防止 TOCTOU + cache_.erase(*name); + } + get(*name); // 重新读取并缓存(get 内部自己加锁) + return {*name}; + } + // 清空全部缓存 + std::vector dropped; + std::lock_guard lock(mtx_); + for (const auto& [k, v] : cache_) + dropped.push_back(k); + cache_.clear(); + return dropped; + } + + std::size_t cached_count() const { + std::lock_guard lock(mtx_); + return cache_.size(); + } + +private: + // 扩展名白名单校验(纯字符串操作,不访问文件系统) + static void validate_name(const std::string& name) { + if (name.empty()) + throw ConfigNameInvalid("非法配置名: (空)"); + if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) + throw ConfigNameInvalid("非法配置名"); + auto ext = get_extension(name); + if (kAllowedExt.find(ext) == kAllowedExt.end()) + throw ConfigNameInvalid("不支持的配置类型: " + (ext.empty() ? "(无扩展名)" : ext)); + } + + // 持锁解析路径(canonical 解析符号链接 + 前缀校验 + 文件存在性) + fs::path resolve_locked(const std::string& name) { + auto path = fs::weakly_canonical(root_ / name); + std::string path_str = path.string(); + std::string root_str = root_.string(); + + // 校验路径在 root_ 之下 + if (path_str == root_str || + path_str.size() <= root_str.size() || + path_str[root_str.size()] != '/' || + path_str.find(root_str) != 0) + throw ConfigNameInvalid("非法配置名"); + + if (!fs::is_regular_file(path)) + throw ConfigNotFound("配置不存在: " + name); + + return path; + } + + fs::path root_; + std::unordered_map cache_; + mutable std::mutex mtx_; +}; + +// ═══════════════════════════════════════════════════════════════════════ +// 4. INI 按键取值 — 与 Python ConfigParser(interpolation=None) 行为对等 +// ═══════════════════════════════════════════════════════════════════════ +// +// 大小写策略(与 Python configparser 一致): +// - Section 名:大小写敏感([Server] ≠ [server]) +// - Option 名:大小写不敏感(port = Port = PORT) +// - 值:原样返回,不做任何转换 +// - 插值:禁用,%(key)s 原样返回 + +static std::optional extract_ini_key( + const std::string& content, const std::string& key) +{ + auto dot = key.find('.'); + if (dot == std::string::npos || dot == 0 || dot == key.size() - 1) + return std::nullopt; + + std::string section = key.substr(0, dot); + std::string option = key.substr(dot + 1); + // option 大小写不敏感 → 转小写比较 + std::string option_lower = option; + std::transform(option_lower.begin(), option_lower.end(), option_lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + std::istringstream stream(content); + std::string line; + std::string current_section; + + while (std::getline(stream, line)) { + // 去 \r + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + auto start = line.find_first_not_of(" \t"); + if (start == std::string::npos) continue; // 空行 + if (line[start] == ';' || line[start] == '#') continue; // 注释 + + if (line[start] == '[') { + auto end = line.find(']', start); + if (end != std::string::npos) + current_section = line.substr(start + 1, end - start - 1); + continue; + } + + auto eq = line.find('=', start); + if (eq == std::string::npos) continue; + + std::string key_name = line.substr(start, eq - start); + auto ke = key_name.find_last_not_of(" \t"); + if (ke != std::string::npos) key_name = key_name.substr(0, ke + 1); + + std::string key_lower = key_name; + std::transform(key_lower.begin(), key_lower.end(), key_lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + std::string value = line.substr(eq + 1); + auto vs = value.find_first_not_of(" \t"); + if (vs != std::string::npos) value = value.substr(vs); + auto ve = value.find_last_not_of(" \t\r"); + if (ve != std::string::npos) value = value.substr(0, ve + 1); + + if (current_section == section && key_lower == option_lower) + return value; + } + + return std::nullopt; +} + +// ═══════════════════════════════════════════════════════════════════════ +// 5. JSON 点路径取值 +// ═══════════════════════════════════════════════════════════════════════ + +static json::value extract_json_key( + const std::string& content, const std::string& key) +{ + json::value node = json::parse(content); // 解析失败会抛异常 + + // 按点分割 + std::vector parts; + std::istringstream iss(key); + std::string part; + while (std::getline(iss, part, '.')) { + if (!part.empty()) parts.push_back(part); + } + + for (const auto& p : parts) { + if (node.is_object()) { + auto it = node.as_object().find(p); + if (it == node.as_object().end()) + throw KeyNotFound("键不存在: -> " + key); + node = it->value(); + } else if (node.is_array()) { + if (p.empty() || !std::all_of(p.begin(), p.end(), [](char c) { return std::isdigit(c); })) + throw KeyNotFound("数组下标必须是数字: " + p); + std::size_t idx = std::stoul(p); + if (idx >= node.as_array().size()) + throw KeyNotFound("数组下标越界: " + p); + node = node.as_array()[idx]; + } else { + throw KeyNotFound("无法继续遍历: " + p); + } + } + return node; +} + +// ═══════════════════════════════════════════════════════════════════════ +// 6. 配置结构 +// ═══════════════════════════════════════════════════════════════════════ + +struct ConfsConfig { + std::string host = "127.0.0.1"; + int port = 8010; + int workers = 4; + std::string config_dir; // 默认同级 configs/ +}; + +static void apply_env_overrides(ConfsConfig& cfg) { + if (auto* v = std::getenv("LISTEN_ADDR")) cfg.host = v; + if (auto* v = std::getenv("LISTEN_PORT")) cfg.port = parse_int("LISTEN_PORT", v); + if (auto* v = std::getenv("WORKERS")) cfg.workers = parse_int("WORKERS", v); + if (auto* v = std::getenv("CONFS_DIR")) cfg.config_dir = v; +} + +static void validate_config(const ConfsConfig& cfg) { + if (cfg.host.empty()) { std::cerr << "[confs2] 配置缺失: host" << std::endl; std::exit(1); } + if (cfg.port <= 0 || cfg.port > 65535) { std::cerr << "[confs2] 配置无效: port" << std::endl; std::exit(1); } + if (cfg.workers < 0) { std::cerr << "[confs2] workers 不能为负数" << std::endl; std::exit(1); } + if (cfg.config_dir.empty()) { std::cerr << "[confs2] 配置缺失: config_dir" << std::endl; std::exit(1); } +} + +// ═══════════════════════════════════════════════════════════════════════ +// 7. 全局实例 + 路由 + main() +// ═══════════════════════════════════════════════════════════════════════ + +static ConfsConfig g_config; +static ConfigStore* g_store = nullptr; +static auto& g_log = LogC::instance(); +static const auto g_start_time = std::chrono::steady_clock::now(); + +static std::int64_t uptime_seconds() { + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration_cast(now - g_start_time).count(); +} + +int main(int argc, char* argv[]) { + try { + // ── 默认配置目录:同级 configs/ ── + { + // 可执行文件同目录下的 configs/ + // 编译时不知道运行时路径,用 __FILE__ 作为开发期 fallback + g_config.config_dir = "./configs"; + } + + // ── 命令行参数 ── + po::options_description desc("confs2 — C++20 统一配置服务(pymsg RPC)"); + desc.add_options() + ("help,h", "显示帮助") + ("addr,a", po::value(), "监听地址") + ("port,p", po::value(), "监听端口") + ("workers,w", po::value(), "工作线程数") + ("dir,d", po::value(), "配置文件目录"); + po::variables_map vm; + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + + if (vm.count("help")) { + std::cout << desc << std::endl; + return 0; + } + + // ── 配置加载:硬编码默认 → 环境变量 → 命令行 ── + apply_env_overrides(g_config); + if (vm.count("addr")) g_config.host = vm["addr"].as(); + if (vm.count("port")) g_config.port = vm["port"].as(); + if (vm.count("workers")) g_config.workers = vm["workers"].as(); + if (vm.count("dir")) g_config.config_dir = vm["dir"].as(); + + validate_config(g_config); + + // ── 初始化 ConfigStore ── + ConfigStore store(g_config.config_dir); + g_store = &store; + + std::cerr << "[confs2] 配置目录: " << store.root_string() << std::endl; + std::cerr << "[confs2] 监听 tcp://" << g_config.host << ":" << g_config.port + << " workers=" << g_config.workers << std::endl; + + g_log.info("confs", "启动中... 监听 tcp://" + g_config.host + ":" + + std::to_string(g_config.port) + " 配置目录 " + store.root_string()); + + // ── 启动 RpcServer ── + pmc::net::RpcServer rpc(g_config.host, g_config.port, + pmc::net::MsgServerConfig{.workers = g_config.workers}, + [](const std::string& route, const std::exception& e) { + g_log.error("confs", "路由 " + route + " 处理异常: " + e.what()); + }); + + // ── 路由: get ── + rpc.add_route("get", [](const json::object& req) -> json::value { + auto name_it = req.find("name"); + if (name_it == req.end() || !name_it->value().is_string()) + throw ConfigError("缺少 name 字段"); + std::string name = json::value_to(name_it->value()); + + auto entry = g_store->get(name); + + // 无 key:返回全文 + auto key_it = req.find("key"); + if (key_it == req.end()) + return json::object{ + {"name", name}, + {"content", entry.content}, + {"size", (std::int64_t)entry.size}, + {"mtime", iso_from_mtime(entry.mtime)} + }; + + // 有 key:校验 + if (!key_it->value().is_string()) + throw ConfigError("key 字段必须是字符串"); + std::string key = json::value_to(key_it->value()); + if (key.empty()) + throw ConfigError("key 字段不能为空"); + + auto ext = get_extension(name); + + if (ext == ".ini" || ext == ".conf" || ext == ".cfg") { + auto val = extract_ini_key(entry.content, key); + if (!val) + throw KeyNotFound("键不存在: " + name + " -> " + key); + g_log.info("confs", "分发配置键 " + name + " -> " + key); + return json::object{{"name", name}, {"key", key}, {"value", *val}}; + } + + if (ext == ".json") { + auto val = extract_json_key(entry.content, key); + g_log.info("confs", "分发配置键 " + name + " -> " + key); + return json::object{{"name", name}, {"key", key}, {"value", val}}; + } + + throw ConfigError(ext + " 类型不支持按键查询,请取全文后自行解析"); + }); + + // ── 路由: list ── + rpc.add_route("list", [](const json::object&) -> json::value { + auto configs = g_store->list(); + json::array arr; + for (const auto& c : configs) + arr.push_back(json::object{ + {"name", c.name}, + {"size", (std::int64_t)c.size}, + {"mtime", c.mtime_iso}, + {"cached", c.cached} + }); + return json::object{ + {"config_dir", g_store->root_string()}, + {"configs", arr} + }; + }); + + // ── 路由: reload ── + rpc.add_route("reload", [](const json::object& req) -> json::value { + auto it = req.find("name"); + + // 省略 name 或 null → 清空全部缓存 + if (it == req.end() || it->value().is_null()) { + auto names = g_store->reload(nullptr); + g_log.info("confs", "清空配置缓存(" + std::to_string(names.size()) + " 项)"); + json::array arr; + for (const auto& n : names) arr.push_back(json::value(n)); + return json::object{{"reloaded", arr}}; + } + + // 非字符串 → 报错 + if (!it->value().is_string()) + throw ConfigError("name 字段必须是字符串"); + + std::string name = json::value_to(it->value()); + if (name.empty()) + throw ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)"); + + auto names = g_store->reload(&name); + g_log.info("confs", "重载配置 " + names[0]); + json::array arr; + for (const auto& n : names) arr.push_back(json::value(n)); + return json::object{{"reloaded", arr}}; + }); + + // ── 路由: health ── + rpc.add_route("health", [](const json::object&) -> json::value { + return json::object{ + {"status", "ok"}, + {"service", "confs"}, + {"config_dir", g_store->root_string()}, + {"cached", (std::int64_t)g_store->cached_count()}, + {"uptime", uptime_seconds()} + }; + }); + + std::cerr << "[confs2] 开始监听 tcp://" << g_config.host << ":" + << g_config.port << std::endl; + g_log.info("confs", "开始监听 tcp://" + g_config.host + ":" + + std::to_string(g_config.port)); + + rpc.start(); // 阻塞 + + } catch (const std::exception& e) { + std::cerr << "[confs2] 致命错误: " << e.what() << std::endl; + g_log.error("confs", std::string("致命错误: ") + e.what()); + return 1; + } + + return 0; +} diff --git a/confs2/test_confs2.py b/confs2/test_confs2.py new file mode 100644 index 0000000..3e3b2c0 --- /dev/null +++ b/confs2/test_confs2.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""confs2 vs confs 对比测试 — 21 项路由逐一对比 Python 原版与 C++ 重写版。 + +智能比较策略: +- mtime 字段:截断到秒精度(Python float 与 C++ 纳秒转微秒有 ±1µs 精度差异) +- health 路由:比较结构(字段集 + status + service + config_dir),忽略 uptime/cached +- reload all:集合比较(dict 保序 vs unordered_map 哈希序,条目集一致即可) +""" +import sys, json, os, re +sys.path.insert(0, "/opt/micro_servers/confs/venv/lib/python3.12/site-packages") +sys.path.insert(0, "/opt/micro_servers/pymsg") +from pymsg import RpcClient + +PORT_OLD = 8010 # Python confs +PORT_NEW = 8011 # C++ confs2 + +def call_both(route, params): + c_old = RpcClient("127.0.0.1", PORT_OLD, "test", timeout=5.0) + c_new = RpcClient("127.0.0.1", PORT_NEW, "test", timeout=5.0) + try: + r_old = c_old.call(route, params); old_ok = True + except Exception as e: + r_old = str(e); old_ok = False + try: + r_new = c_new.call(route, params); new_ok = True + except Exception as e: + r_new = str(e); new_ok = False + return r_old, r_new, old_ok, new_ok + +def call_both_raw(route, params): + c_old = RpcClient("127.0.0.1", PORT_OLD, "test", timeout=5.0) + c_new = RpcClient("127.0.0.1", PORT_NEW, "test", timeout=5.0) + try: + r_old = c_old.call_raw(route, params); old_ok = r_old.get("success", False) + except Exception as e: + r_old = {"success": False, "error": str(e)}; old_ok = False + try: + r_new = c_new.call_raw(route, params); new_ok = r_new.get("success", False) + except Exception as e: + r_new = {"success": False, "error": str(e)}; new_ok = False + return r_old, r_new, old_ok, new_ok + +def normalize_mtime(s): + if isinstance(s, str): + return re.sub(r'\.\d+(?=\+00:00|Z|$)', '', s) + return s + +def normalize_obj(obj): + if isinstance(obj, dict): + return {k: (normalize_mtime(v) if k == 'mtime' else normalize_obj(v)) for k, v in obj.items()} + elif isinstance(obj, list): + return [normalize_obj(item) for item in obj] + return obj + +results = [] + +def report(label, match, old, new, ok_old, ok_new): + status = "PASS" if match else "FAIL" + print(f"[{status}] {label}") + if not match: + print(f" OLD: ok={ok_old} -> {json.dumps(old, default=str)[:300]}") + print(f" NEW: ok={ok_new} -> {json.dumps(new, default=str)[:300]}") + results.append(match) + +# 1. health (ignore uptime/cached) +r_old, r_new, ok_old, ok_new = call_both("health", {}) +if ok_old and ok_new: + keys_match = set(r_old.keys()) == set(r_new.keys()) + vals_match = all(r_old.get(k) == r_new.get(k) for k in ["status", "service", "config_dir"]) + report("health", keys_match and vals_match, r_old, r_new, ok_old, ok_new) +else: + report("health", ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 2. list (normalize mtime) +r_old, r_new, ok_old, ok_new = call_both("list", {}) +if ok_old and ok_new: + report("list", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new) +else: + report("list", ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 3. get full content - db.ini (normalize mtime) +r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini"}) +if ok_old and ok_new: + report("get db.ini full", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new) +else: + report("get db.ini full", ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 4-6. get INI keys +for key, label in [("server.port", "server.port"), ("database.url", "database.url"), ("logs.retention_days", "logs.retention_days")]: + r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini", "key": key}) + report(f"get db.ini {label}", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new) + +# 7. case-insensitive option +r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini", "key": "server.PORT"}) +report("get db.ini server.PORT (case-insensitive)", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new) + +# 8-9. static.ini keys +for key, label in [("server.port", "server.port"), ("cors.allowed_origins", "cors.allowed_origins")]: + r_old, r_new, ok_old, ok_new = call_both("get", {"name": "static.ini", "key": key}) + report(f"get static.ini {label}", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new) + +# 10. get full content - captcha.ini (normalize mtime) +r_old, r_new, ok_old, ok_new = call_both("get", {"name": "captcha.ini"}) +if ok_old and ok_new: + report("get captcha.ini full", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new) +else: + report("get captcha.ini full", ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 11-13. error cases +for params, label in [ + ({"name": "nonexistent.ini"}, "get nonexistent (expect error)"), + ({"name": "../../etc/passwd"}, "get path traversal (expect error)"), + ({"name": "test.sh"}, "get invalid extension (expect error)"), +]: + r_old, r_new, ok_old, ok_new = call_both_raw("get", params) + report(label, ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 14. reload specific +r_old, r_new, ok_old, ok_new = call_both("reload", {"name": "db.ini"}) +report("reload db.ini", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new) + +# 15. reload all (set comparison — dict vs unordered_map) +r_old, r_new, ok_old, ok_new = call_both("reload", {}) +if ok_old and ok_new: + old_set = set(r_old.get("reloaded", [])) + new_set = set(r_new.get("reloaded", [])) + report("reload all", old_set == new_set, r_old, r_new, ok_old, ok_new) +else: + report("reload all", ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# 16-21. error cases +for route, params, label in [ + ("reload", {"name": ""}, "reload empty string (expect error)"), + ("reload", {"name": 123}, "reload non-string (expect error)"), + ("get", {"name": ""}, "get empty name (expect error)"), + ("get", {}, "get without name (expect error)"), + ("get", {"name": "db.ini", "key": "nonexistent.key"}, "get db.ini nonexistent.key (expect error)"), + ("get", {"name": "db.ini", "key": "noformat"}, "get db.ini noformat (expect error)"), +]: + r_old, r_new, ok_old, ok_new = call_both_raw(route, params) + report(label, ok_old == ok_new, r_old, r_new, ok_old, ok_new) + +# Summary +passed = sum(1 for r in results if r) +total = len(results) +print(f"\n{'='*60}") +print(f"Results: {passed}/{total} passed") +if passed < total: + print(f"FAILED: {total - passed} tests") + sys.exit(1) +else: + print("ALL TESTS PASSED!") + sys.exit(0) diff --git a/core/init.txt b/core/init.txt index a739ccd..22f91aa 100644 --- a/core/init.txt +++ b/core/init.txt @@ -1,5 +1,5 @@ bash /opt/micro_servers/db2/_run.sh -bash /opt/micro_servers/confs/_run.sh +bash /opt/micro_servers/confs2/_run.sh bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/dnfs2/_run.sh bash /opt/micro_servers/smtp/_run.sh From 59c5fe1feb2ad346a3b0b79f135385b88187ba91 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 15:36:32 +0000 Subject: [PATCH 005/196] =?UTF-8?q?feat(core):=20confs2=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=20core=EF=BC=8C=E5=86=85=E5=B5=8C=20confs=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9C=8D=E5=8A=A1=20v0.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ConfsTask(ITask,与 HttpTask 同构):ConfigStore + INI/JSON 取值 + get/list/reload/health 四路由,协议与 Python confs 逐字节兼容 - 新增 ConfsLogC 异步日志客户端(沿用 confs2 LogC,服务名 confs) - main.cpp 新增 --confs 选项与 CONFS_* 环境变量;不传则行为不变 - Makefile 链接 -lus_msg,版本 0.0.4 → 0.1.0 - _run.sh 启用 --confs 8010;_set.sh 自建 configs → ../confs/configs 链接 - init.txt 移除 confs2 自启动行(由 core 内嵌承担) - 黑盒对比测试 16/16 通过(tests/blackbox/test_core_confs.py) --- core/.gitignore | 3 + core/Makefile | 9 +- core/README.md | 12 + core/_run.sh | 2 +- core/_set.sh | 12 + core/include/main/ConfsLogC.hpp | 132 +++++++ core/include/main/ConfsTask.hh | 63 ++++ core/init.txt | 1 - core/src/main.cpp | 44 ++- core/src/main/ConfsTask.cpp | 557 ++++++++++++++++++++++++++++++ tests/blackbox/test_core_confs.py | 120 +++++++ 11 files changed, 947 insertions(+), 8 deletions(-) create mode 100644 core/include/main/ConfsLogC.hpp create mode 100644 core/include/main/ConfsTask.hh create mode 100644 core/src/main/ConfsTask.cpp create mode 100644 tests/blackbox/test_core_confs.py diff --git a/core/.gitignore b/core/.gitignore index 5e246e0..79115b1 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -130,3 +130,6 @@ go.sum # VS x64 .vs + +# configs 符号链接(由 _set.sh 创建,指向 ../confs/configs/,内嵌 confs 服务使用) +configs diff --git a/core/Makefile b/core/Makefile index 8f44c44..7e888e6 100644 --- a/core/Makefile +++ b/core/Makefile @@ -1,4 +1,4 @@ -VERSION := 0.0.4 +VERSION := 0.1.0 APP_NAME := nidus @@ -16,7 +16,7 @@ CPP:= g++ INCS:= -I include/ -MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lcurl +MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lus_msg -lcurl LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem CPPFLAGS := --std=c++20 -O3 -pipe #-Wall -Werror @@ -26,7 +26,7 @@ $(shell mkdir -p $(BUILD_DIR)) #-------- all: ${APP_NAME}.out -${APP_NAME}.out: $(BUILD_DIR)/main.o $(BUILD_DIR)/PPool.o $(BUILD_DIR)/ProcessTask.o $(BUILD_DIR)/HttpTask.o $(BUILD_DIR)/Tttask.o $(BUILD_DIR)/pmc_mtd.o $(BUILD_DIR)/subsys_call.o +${APP_NAME}.out: $(BUILD_DIR)/main.o $(BUILD_DIR)/PPool.o $(BUILD_DIR)/ProcessTask.o $(BUILD_DIR)/HttpTask.o $(BUILD_DIR)/ConfsTask.o $(BUILD_DIR)/Tttask.o $(BUILD_DIR)/pmc_mtd.o $(BUILD_DIR)/subsys_call.o $(CPP) $^ $(MY_LIBS) $(LIBS) -o $@ $(BUILD_DIR)/main.o: src/main.cpp @@ -44,6 +44,9 @@ $(BUILD_DIR)/Tttask.o: src/main/Tttask.cpp $(BUILD_DIR)/HttpTask.o: src/main/HttpTask.cpp $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ +$(BUILD_DIR)/ConfsTask.o: src/main/ConfsTask.cpp + $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ + $(BUILD_DIR)/pmc_mtd.o: src/main/pmc_mtd.cpp $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ diff --git a/core/README.md b/core/README.md index 5e3f3b1..7d776a4 100644 --- a/core/README.md +++ b/core/README.md @@ -49,6 +49,18 @@ PMC在启动时生成一个AES密钥,然后用一个RSA公钥将其加密发 然后访问者与客户端之间的交流全部用此AES密钥加密 +## 内嵌 confs 配置服务(v0.1.0 起) +core 内置了统一配置服务(由 confs2 合并而来,pymsg RPC 协议,与 Python confs 完全兼容): + +```bash +./nidus.out -i init.txt --serve 5200 --confs 8010 +``` + +- `--confs `:启用内嵌 confs 配置服务(get/list/reload/health 四个路由) +- 不传 `--confs` 时行为与原版完全一致 +- 配置文件目录默认为 `./configs`(_set.sh 会创建指向 `../confs/configs` 的符号链接) +- 环境变量:`CONFS_ADDR` / `CONFS_PORT` / `CONFS_WORKERS` / `CONFS_DIR` + ## 如何使用并发机器运行程序 首先应该在模块中创建一个启动脚本,脚本中应该完成工作目录的切换、虚拟环境的激活,以及程序的启动。 diff --git a/core/_run.sh b/core/_run.sh index e16b5f8..dde1669 100644 --- a/core/_run.sh +++ b/core/_run.sh @@ -1,4 +1,4 @@ #!/bin/bash cd "$(dirname "$0")" || exit export LD_LIBRARY_PATH=/lib:$LD_LIBRARY_PATH -./nidus.out -i init.txt --serve 5200 +./nidus.out -i init.txt --serve 5200 --confs 8010 diff --git a/core/_set.sh b/core/_set.sh index d8a7788..5a1049f 100644 --- a/core/_set.sh +++ b/core/_set.sh @@ -1,4 +1,16 @@ #!/bin/bash +set -euo pipefail cd "$(dirname "$0")" || exit + make clear && make $@ + +# 创建 configs 符号链接(指向 ../confs/configs/,内嵌 confs 服务配置文件零迁移) +if [ -L configs ] && [ ! -e configs ]; then + rm -f configs +fi +if [ ! -e configs ]; then + ln -s ../confs/configs configs + echo "[core] 已创建 configs → ../confs/configs 符号链接" +fi + make install diff --git a/core/include/main/ConfsLogC.hpp b/core/include/main/ConfsLogC.hpp new file mode 100644 index 0000000..cd3d8d5 --- /dev/null +++ b/core/include/main/ConfsLogC.hpp @@ -0,0 +1,132 @@ +/* + * ConfsLogC.hpp — core 内嵌 confs 服务的异步日志客户端 + * + * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 + * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) + * - fail-safe:db 不可用时静默丢弃 + * - API: info() / warning() / error() + * + * 改编自 confs2/inc/LogC.hpp,仅改 namespace(qing::confs), + * 服务名保持 "confs" 与 Python/standalone confs2 日志连续。 + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qing { +namespace confs { + +struct ConfsLogEntry { + std::string ts; + std::string service; + std::string level; + std::string message; + std::string trace_id; + std::string extra; +}; + +class ConfsLogC { +public: + static ConfsLogC& instance() { + static ConfsLogC inst; + return inst; + } + + void info(const std::string& svc, const std::string& msg) { + push(svc, "INFO", msg); + } + void warning(const std::string& svc, const std::string& msg) { + push(svc, "WARNING", msg); + } + void error(const std::string& svc, const std::string& msg) { + push(svc, "ERROR", msg); + } + +private: + ConfsLogC() { + const char* host = std::getenv("DB_HOST"); + const char* port = std::getenv("DB_PORT"); + db_host_ = host ? host : "127.0.0.1"; + db_port_ = port ? std::atoi(port) : 8005; + + queue_ = std::make_unique>( + [this](std::vector&& batch) { send_batch(std::move(batch)); }, + pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, + [](const std::exception& e, std::vector&&) { + std::cerr << "[core/confs-log] send failed: " << e.what() << std::endl; + }); + queue_->start(); + } + + ~ConfsLogC() { if (queue_) queue_->shutdown(); } + + // P2-3 修复:过滤控制字符,防止日志注入 + static std::string sanitize_for_log(std::string s) { + s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c) { + return c < 0x20 && c != '\t'; + }), s.end()); + return s; + } + + void push(const std::string& svc, const std::string& level, const std::string& msg) { + auto now = std::chrono::system_clock::now(); + auto t = std::chrono::system_clock::to_time_t(now); + // 系统本地时间 + 时区偏移 + std::tm* lt = std::localtime(&t); + char buf[40]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); + char tz_raw[8]; + std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); + std::string tz = tz_raw; + if (tz.length() >= 5) + tz = tz.substr(0, 3) + ":" + tz.substr(3); + + ConfsLogEntry e; + e.ts = std::string(buf) + tz; + e.service = sanitize_for_log(svc); + e.level = level; + e.message = sanitize_for_log(msg); + queue_->push(std::move(e)); + } + + void send_batch(std::vector&& batch) { + try { + pmc::net::MsgClient client(db_host_, db_port_, "confs-log"); + for (auto& e : batch) { + boost::json::object vals; + vals["ts"] = e.ts; + vals["service"] = e.service; + vals["level"] = e.level; + vals["message"] = e.message; + vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); + vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); + + boost::json::object req; + req["_route"] = "execute"; + req["action"] = "insert"; + req["table"] = "Logs"; + req["values"] = vals; + + client.call(boost::json::serialize(req), 1.0); + } + } catch (const std::exception& e) { + std::cerr << "[core/confs-log] batch drop: " << e.what() << std::endl; + } + } + + std::string db_host_; + int db_port_; + std::unique_ptr> queue_; +}; + +} // namespace confs +} // namespace qing diff --git a/core/include/main/ConfsTask.hh b/core/include/main/ConfsTask.hh new file mode 100644 index 0000000..61b94ed --- /dev/null +++ b/core/include/main/ConfsTask.hh @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace qing { + +/* 配置仓库前置声明(实现位于 ConfsTask.cpp,保持头文件干净) */ +class ConfigStore; + +/* ---------------- + * 【内嵌 confs 配置服务任务】 + * ---------------- + * 将 confs2(C++20 统一配置服务,pymsg RpcServer)内嵌为 core 的一个 ITask, + * 与 HttpTask / Tttask 并列,由 taskPool 统一管理生命周期。 + * + * - 协议与 Python confs / standalone confs2 字节级兼容,消费方零改动 + * - 仅监听 127.0.0.1,路由 get/list/reload/health 不变 + * - 不传 --confs 参数时 core 不加载本任务,行为与原版完全一致 + */ +class ConfsTask: public ITask { +public: + + /* 构造内嵌配置服务任务 + * + * addr: 监听地址(默认 127.0.0.1) + * port: 监听端口(默认 8010) + * workers: RpcServer 工作线程数(默认 4) + * config_dir: 配置文件目录(默认 ./configs) */ + ConfsTask(std::string addr, int port, int workers, std::string config_dir); + + /* 析构需定义于 .cpp(unique_ptr 持有不完整类型 ConfigStore) */ + ~ConfsTask(); + + /* 任务是否在运行中 */ + bool isRunning() override; + + /* 启动任务(激活线程并等待 RpcServer 就绪) */ + void start() override; + + /* 终止任务(停止 RpcServer 并等待线程关闭) */ + void stop() override; + +private: + std::string addr; /* 监听地址 */ + int port; /* 监听端口 */ + int workers; /* 工作线程数 */ + std::unique_ptr store; /* 配置仓库 */ + std::unique_ptr th; /* 可控线程指针 */ + std::unique_ptr server; /* RPC 服务器指针 */ + std::chrono::steady_clock::time_point started_at; /* 启动时间(health uptime) */ + + /* 初始化线程,定义线程的行为(RpcServer) */ + void init_thread(); + + /* 注册 pymsg 路由(get/list/reload/health) */ + void register_routes(pmc::net::RpcServer& rpc); +}; + +} diff --git a/core/init.txt b/core/init.txt index 22f91aa..eff798f 100644 --- a/core/init.txt +++ b/core/init.txt @@ -1,5 +1,4 @@ bash /opt/micro_servers/db2/_run.sh -bash /opt/micro_servers/confs2/_run.sh bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/dnfs2/_run.sh bash /opt/micro_servers/smtp/_run.sh diff --git a/core/src/main.cpp b/core/src/main.cpp index c38a7a9..b6ec8c0 100644 --- a/core/src/main.cpp +++ b/core/src/main.cpp @@ -32,6 +32,7 @@ #include "tt/Pipe.h" #include "main/Tttask.hh" #include "main/HttpTask.hh" +#include "main/ConfsTask.hh" #include "main/pmc_mtd.hh" #include "main/subsys_call.hh" @@ -39,7 +40,7 @@ #include "cn/中文化.hpp" /* 静态区 - 存放全局变量 */ -static const char *PMC_VERSION = "0.0.10"; /* 版本号 */ +static const char *PMC_VERSION = "0.1.0"; /* 版本号 */ static const size_t MSGLEN = 2048; /* 单条消息的长度 */ static const size_t MSGCNT = 100; /* 消息条数 */ @@ -52,8 +53,17 @@ namespace po = boost::program_options; void pmc_init(po::variables_map&); /* 初始化pmc并行机器 */ void pmc_serve(po::variables_map&); /* pmc的http伺服器模式 */ +void pmc_confs(po::variables_map&); /* pmc内嵌confs配置服务 */ void parse_data_from_mq(std::string&); /* 解析消息队列收到的消息 */ +/* 环境变量整数解析(失败时回退默认值,不终止进程) */ +static int env_or_default(const char* key, int fallback) { + auto* v = std::getenv(key); + if (!v) return fallback; + try { return std::stoi(v); } + catch (...) { return fallback; } +} + //------------------------------------------------------------- //------------------------------------------------------------- @@ -140,7 +150,8 @@ int main(int argc, char** argv) { ("kill", po::value(), "kill a process") /* 杀死一个进程 */ /********* 子系统服务模式 ********/ ("init,i", po::value(), "self-init list") /* 输入自启动列表 */ - ("serve", po::value(), "Run as http server"); /* 启用http伺服器 */ + ("serve", po::value(), "Run as http server") /* 启用http伺服器 */ + ("confs", po::value(), "Run embedded confs rpc server"); /* 启用内嵌confs配置服务 */ /* 参数变量映射关系 */ po::variables_map vm; @@ -210,7 +221,11 @@ int main(int argc, char** argv) { /* TODO: 抽象出来 */ if (vm.count("serve")) pmc_serve(vm); - + + /* 内嵌confs配置服务 */ + if (vm.count("confs")) + pmc_confs(vm); + /* pmc初始化函数 */ pmc_init(vm); @@ -259,6 +274,29 @@ void pmc_serve(po::variables_map& vm) } +/* 内嵌confs配置服务(pymsg RPC,只监听本地) + * + * 环境变量覆盖(CONFS_* 前缀,避免 core 环境变量污染子进程): + * CONFS_ADDR 监听地址(默认 127.0.0.1) + * CONFS_PORT 监听端口(被 --confs 命令行参数覆盖) + * CONFS_WORKERS 工作线程数(默认 4) + * CONFS_DIR 配置文件目录(默认 ./configs) */ +void pmc_confs(po::variables_map& vm) +{ + std::string addr = "127.0.0.1"; + int workers = 4; + std::string dir = "./configs"; + if (auto* v = std::getenv("CONFS_ADDR")) addr = v; + if (auto* v = std::getenv("CONFS_DIR")) dir = v; + workers = env_or_default("CONFS_WORKERS", workers); + + int port = vm["confs"].as(); + + auto confs = std::make_shared (addr, port, workers, dir); + confs->start(); + taskPool.push_back(confs); +} + diff --git a/core/src/main/ConfsTask.cpp b/core/src/main/ConfsTask.cpp new file mode 100644 index 0000000..ce8f514 --- /dev/null +++ b/core/src/main/ConfsTask.cpp @@ -0,0 +1,557 @@ +/* + * ConfsTask.cpp — core 内嵌 confs 统一配置服务(pymsg RPC) + * + * 业务逻辑移植自 confs2/src/main.cpp(C++20 统一配置服务), + * ConfigStore / INI 按键取值 / JSON 点路径 / 四个路由与原实现逐项对齐, + * 协议与 Python confs 字节级兼容,消费方零改动。 + * + * 与 standalone confs2 的差异: + * - 入口由 main() 改为 ConfsTask(ITask),生命周期由 core 的 taskPool 管理 + * - 引导配置由 core 的 main.cpp 解析(--confs / CONFS_* 环境变量)后注入 + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "main/ConfsTask.hh" +#include "main/ConfsLogC.hpp" + +namespace json = boost::json; +namespace fs = std::filesystem; + +using qing::confs::ConfsLogC; + +namespace qing { + +// ═══════════════════════════════════════════════════════════════════════ +// 1. 常量与异常 +// ═══════════════════════════════════════════════════════════════════════ + +namespace { + +const std::unordered_set kAllowedExt = { + ".ini", ".conf", ".cfg", ".json", ".yaml", ".yml", ".toml", ".txt" +}; + +constexpr std::size_t kMaxConfigSize = 1 * 1024 * 1024; // 1 MiB + +class ConfigError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; +class ConfigNotFound : public ConfigError { +public: + using ConfigError::ConfigError; +}; +class ConfigNameInvalid : public ConfigError { +public: + using ConfigError::ConfigError; +}; +class KeyNotFound : public ConfigError { +public: + using ConfigError::ConfigError; +}; + +// ═══════════════════════════════════════════════════════════════════════ +// 2. 工具函数 +// ═══════════════════════════════════════════════════════════════════════ + +std::string get_extension(const std::string& name) { + auto dot = name.rfind('.'); + if (dot == std::string::npos) return ""; + std::string ext = name.substr(dot); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return std::tolower(c); }); + return ext; +} + +std::string iso_from_mtime(fs::file_time_type mtime) { + auto now_sys = std::chrono::system_clock::now(); + auto now_file = fs::file_time_type::clock::now(); + auto sys_tp = std::chrono::time_point_cast( + mtime - now_file + now_sys); + std::time_t t = std::chrono::system_clock::to_time_t(sys_tp); + + // 提取微秒部分(与 Python datetime.isoformat() 一致) + auto us = std::chrono::duration_cast( + sys_tp.time_since_epoch()).count() % 1000000; + + // 系统本地时间 + 时区偏移 + std::tm* lt = std::localtime(&t); + char buf[48]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); + char tz_raw[8]; + std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); // "+0000" or "+0800" + std::string tz = tz_raw; + if (tz.length() >= 5) + tz = tz.substr(0, 3) + ":" + tz.substr(3); // "+00:00" + + if (us == 0) + return std::string(buf) + tz; // Python: 微秒为0时省略 + char out[64]; + std::snprintf(out, sizeof(out), "%s.%06lld%s", + buf, static_cast(us), tz.c_str()); + return out; +} + +} // anonymous namespace + +// ═══════════════════════════════════════════════════════════════════════ +// 3. ConfigStore — 配置文件仓库(线程安全) +// ═══════════════════════════════════════════════════════════════════════ + +struct CacheEntry { + std::string content; + fs::file_time_type mtime; + std::size_t size; +}; + +struct ConfigMeta { + std::string name; + std::size_t size; + std::string mtime_iso; + bool cached; +}; + +class ConfigStore { +public: + explicit ConfigStore(const std::string& root) { + root_ = fs::weakly_canonical(fs::absolute(root)); + fs::create_directories(root_); + } + + std::string root_string() const { return root_.string(); } + + // 取配置全文(带缓存 + mtime 自动重载) + // 返回 CacheEntry 的拷贝(防缓存污染,与 Python 版 dict(entry) 语义一致) + CacheEntry get(const std::string& name) { + validate_name(name); // 锁外快速拒绝非法名 + std::lock_guard lock(mtx_); + auto path = resolve_locked(name); // 锁内: canonical + 前缀校验 + 存在性检查 + + // 锁内: stat → mtime 比对 → size 校验 → open → read,无 TOCTOU 窗口 + auto mtime = fs::last_write_time(path); + auto size = fs::file_size(path); + + auto it = cache_.find(name); + if (it != cache_.end() && it->second.mtime == mtime) + return it->second; // 缓存命中,返回拷贝 + + if (size > kMaxConfigSize) + throw ConfigError("配置文件过大: " + name); + + std::ifstream f(path); + if (!f.is_open()) + throw ConfigError("配置读取失败: " + name); + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + CacheEntry entry{content, mtime, content.size()}; + cache_[name] = entry; + return entry; // 返回拷贝 + } + + // 列出配置目录全部文件元信息 + std::vector list() { + std::vector out; + std::lock_guard lock(mtx_); + std::string root_str = root_.string(); + + std::vector names; + for (const auto& entry : fs::directory_iterator(root_)) { + auto name = entry.path().filename().string(); + if (kAllowedExt.find(get_extension(name)) == kAllowedExt.end()) + continue; + // 解析符号链接,逃逸的跳过 + auto path = fs::weakly_canonical(entry.path()); + std::string path_str = path.string(); + if (path_str.find(root_str) != 0 || path_str == root_str) + continue; + if (path_str.size() > root_str.size() && path_str[root_str.size()] != '/') + continue; + if (!fs::is_regular_file(path)) + continue; + names.push_back(name); + } + + std::sort(names.begin(), names.end()); + + for (const auto& name : names) { + auto path = fs::weakly_canonical(root_ / name); + try { + auto mtime = fs::last_write_time(path); + auto size = fs::file_size(path); + out.push_back({name, size, iso_from_mtime(mtime), + cache_.count(name) > 0}); + } catch (const std::exception&) { + continue; // 文件可能在遍历时被删除,跳过 + } + } + return out; + } + + // 重载指定配置 / 清空全部缓存 + std::vector reload(const std::string* name) { + if (name != nullptr) { + validate_name(*name); + { + std::lock_guard lock(mtx_); + resolve_locked(*name); // 锁内检查存在性,防止 TOCTOU + cache_.erase(*name); + } + get(*name); // 重新读取并缓存(get 内部自己加锁) + return {*name}; + } + // 清空全部缓存 + std::vector dropped; + std::lock_guard lock(mtx_); + for (const auto& [k, v] : cache_) + dropped.push_back(k); + cache_.clear(); + return dropped; + } + + std::size_t cached_count() const { + std::lock_guard lock(mtx_); + return cache_.size(); + } + +private: + // 扩展名白名单校验(纯字符串操作,不访问文件系统) + static void validate_name(const std::string& name) { + if (name.empty()) + throw ConfigNameInvalid("非法配置名: (空)"); + if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) + throw ConfigNameInvalid("非法配置名: '" + name + "'"); + auto ext = get_extension(name); + if (kAllowedExt.find(ext) == kAllowedExt.end()) + throw ConfigNameInvalid("不支持的配置类型: " + (ext.empty() ? "(无扩展名)" : ext)); + } + + // 持锁解析路径(canonical 解析符号链接 + 前缀校验 + 文件存在性) + fs::path resolve_locked(const std::string& name) { + auto path = fs::weakly_canonical(root_ / name); + std::string path_str = path.string(); + std::string root_str = root_.string(); + + // 校验路径在 root_ 之下 + if (path_str == root_str || + path_str.size() <= root_str.size() || + path_str[root_str.size()] != '/' || + path_str.find(root_str) != 0) + throw ConfigNameInvalid("非法配置名: '" + name + "'"); + + if (!fs::is_regular_file(path)) + throw ConfigNotFound("配置不存在: " + name); + + return path; + } + + fs::path root_; + std::unordered_map cache_; + mutable std::mutex mtx_; +}; + +namespace { + +// ═══════════════════════════════════════════════════════════════════════ +// 4. INI 按键取值 — 与 Python ConfigParser(interpolation=None) 行为对等 +// ═══════════════════════════════════════════════════════════════════════ +// +// 大小写策略(与 Python configparser 一致): +// - Section 名:大小写敏感([Server] ≠ [server]) +// - Option 名:大小写不敏感(port = Port = PORT) +// - 值:原样返回,不做任何转换 +// - 插值:禁用,%(key)s 原样返回 + +std::optional extract_ini_key( + const std::string& content, const std::string& key) +{ + auto dot = key.find('.'); + if (dot == std::string::npos || dot == 0 || dot == key.size() - 1) + return std::nullopt; + + std::string section = key.substr(0, dot); + std::string option = key.substr(dot + 1); + // option 大小写不敏感 → 转小写比较 + std::string option_lower = option; + std::transform(option_lower.begin(), option_lower.end(), option_lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + std::istringstream stream(content); + std::string line; + std::string current_section; + + while (std::getline(stream, line)) { + // 去 \r + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + auto start = line.find_first_not_of(" \t"); + if (start == std::string::npos) continue; // 空行 + if (line[start] == ';' || line[start] == '#') continue; // 注释 + + if (line[start] == '[') { + auto end = line.find(']', start); + if (end != std::string::npos) + current_section = line.substr(start + 1, end - start - 1); + continue; + } + + auto eq = line.find('=', start); + if (eq == std::string::npos) continue; + + std::string key_name = line.substr(start, eq - start); + auto ke = key_name.find_last_not_of(" \t"); + if (ke != std::string::npos) key_name = key_name.substr(0, ke + 1); + + std::string key_lower = key_name; + std::transform(key_lower.begin(), key_lower.end(), key_lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + + std::string value = line.substr(eq + 1); + auto vs = value.find_first_not_of(" \t"); + if (vs != std::string::npos) value = value.substr(vs); + auto ve = value.find_last_not_of(" \t\r"); + if (ve != std::string::npos) value = value.substr(0, ve + 1); + + if (current_section == section && key_lower == option_lower) + return value; + } + + return std::nullopt; +} + +// ═══════════════════════════════════════════════════════════════════════ +// 5. JSON 点路径取值 +// ═══════════════════════════════════════════════════════════════════════ + +json::value extract_json_key( + const std::string& content, const std::string& key) +{ + json::value node = json::parse(content); // 解析失败会抛异常 + + // 按点分割 + std::vector parts; + std::istringstream iss(key); + std::string part; + while (std::getline(iss, part, '.')) { + if (!part.empty()) parts.push_back(part); + } + + for (const auto& p : parts) { + if (node.is_object()) { + auto it = node.as_object().find(p); + if (it == node.as_object().end()) + throw KeyNotFound("键不存在: -> " + key); + node = it->value(); + } else if (node.is_array()) { + if (p.empty() || !std::all_of(p.begin(), p.end(), [](char c) { return std::isdigit(c); })) + throw KeyNotFound("数组下标必须是数字: " + p); + std::size_t idx = std::stoul(p); + if (idx >= node.as_array().size()) + throw KeyNotFound("数组下标越界: " + p); + node = node.as_array()[idx]; + } else { + throw KeyNotFound("无法继续遍历: " + p); + } + } + return node; +} + +} // anonymous namespace + +// ═══════════════════════════════════════════════════════════════════════ +// 6. ConfsTask — ITask 实现(生命周期与 HttpTask 同构) +// ═══════════════════════════════════════════════════════════════════════ + +ConfsTask::ConfsTask(std::string addr_, int port_, int workers_, std::string config_dir) + : addr(std::move(addr_)), port(port_), workers(workers_), + started_at(std::chrono::steady_clock::now()) +{ + store = std::make_unique(config_dir); + this->th = std::make_unique(); + init_thread(); +} + +ConfsTask::~ConfsTask() = default; + +bool ConfsTask::isRunning() { + auto stat = th->check(); + return !(stat == Fsm::Stat::STOP || stat == Fsm::Stat::SHUT); +} + +void ConfsTask::start() { + if (!this->isRunning()) { + th->Activate(); + th->WaitStart(); + } +} + +void ConfsTask::stop() { + if (this->server) this->server->stop(); + if (this->isRunning()) + th->WaitClose(); +} + +void ConfsTask::init_thread() { + + /* stop callback 静止事件 */ + this->th->set_stop( [](Thread& th) -> void { + th.suspend(); + }); + + /* wake callback 唤醒事件:创建 RpcServer 并阻塞运行 */ + this->th->set_wake( [this](Thread& th) -> void { + this->server = std::make_unique (addr, port, + pmc::net::MsgServerConfig{.workers = workers}, + [](const std::string& route, const std::exception& e) { + ConfsLogC::instance().error("confs", "路由 " + route + " 处理异常: " + e.what()); + }); + register_routes(*this->server); + th.run(); + + ConfsLogC::instance().info("confs", "开始监听 tcp://" + addr + ":" + + std::to_string(port)); + this->server->start(); /* 堵死线程,等待 stop() 解除 */ + }); + + /* loop callback 循环事件:服务器主动退出时停止线程防死循环 */ + this->th->set_loop( [](Thread& th) -> void { + th.stop(); + }); + + /* clean callback 清理事件 */ + this->th->set_clear( [this](Thread&) -> void { + this->server.reset(); + }); +} + +// ═══════════════════════════════════════════════════════════════════════ +// 7. 路由注册(get / list / reload / health,与 confs2 逐项对齐) +// ═══════════════════════════════════════════════════════════════════════ + +void ConfsTask::register_routes(pmc::net::RpcServer& rpc) { + + /* ── 路由: get ── */ + rpc.add_route("get", [this](const json::object& req) -> json::value { + auto name_it = req.find("name"); + if (name_it == req.end() || !name_it->value().is_string()) + throw ConfigError("缺少 name 字段"); + std::string name = json::value_to(name_it->value()); + + auto entry = store->get(name); + + // 无 key:返回全文 + auto key_it = req.find("key"); + if (key_it == req.end()) + return json::object{ + {"name", name}, + {"content", entry.content}, + {"size", (std::int64_t)entry.size}, + {"mtime", iso_from_mtime(entry.mtime)} + }; + + // 有 key:校验 + if (!key_it->value().is_string()) + throw ConfigError("key 字段必须是字符串"); + std::string key = json::value_to(key_it->value()); + if (key.empty()) + throw ConfigError("key 字段不能为空"); + + auto ext = get_extension(name); + + if (ext == ".ini" || ext == ".conf" || ext == ".cfg") { + auto val = extract_ini_key(entry.content, key); + if (!val) + throw KeyNotFound("键不存在: " + name + " -> " + key); + ConfsLogC::instance().info("confs", "分发配置键 " + name + " -> " + key); + return json::object{{"name", name}, {"key", key}, {"value", *val}}; + } + + if (ext == ".json") { + auto val = extract_json_key(entry.content, key); + ConfsLogC::instance().info("confs", "分发配置键 " + name + " -> " + key); + return json::object{{"name", name}, {"key", key}, {"value", val}}; + } + + throw ConfigError(ext + " 类型不支持按键查询,请取全文后自行解析"); + }); + + /* ── 路由: list ── */ + rpc.add_route("list", [this](const json::object&) -> json::value { + auto configs = store->list(); + json::array arr; + for (const auto& c : configs) + arr.push_back(json::object{ + {"name", c.name}, + {"size", (std::int64_t)c.size}, + {"mtime", c.mtime_iso}, + {"cached", c.cached} + }); + return json::object{ + {"config_dir", store->root_string()}, + {"configs", arr} + }; + }); + + /* ── 路由: reload ── */ + rpc.add_route("reload", [this](const json::object& req) -> json::value { + auto it = req.find("name"); + + // 省略 name 或 null → 清空全部缓存 + if (it == req.end() || it->value().is_null()) { + auto names = store->reload(nullptr); + ConfsLogC::instance().info("confs", "清空配置缓存(" + std::to_string(names.size()) + " 项)"); + json::array arr; + for (const auto& n : names) arr.push_back(json::value(n)); + return json::object{{"reloaded", arr}}; + } + + // 非字符串 → 报错 + if (!it->value().is_string()) + throw ConfigError("name 字段必须是字符串"); + + std::string name = json::value_to(it->value()); + if (name.empty()) + throw ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)"); + + auto names = store->reload(&name); + ConfsLogC::instance().info("confs", "重载配置 " + names[0]); + json::array arr; + for (const auto& n : names) arr.push_back(json::value(n)); + return json::object{{"reloaded", arr}}; + }); + + /* ── 路由: health ── */ + rpc.add_route("health", [this](const json::object&) -> json::value { + auto now = std::chrono::steady_clock::now(); + auto uptime = std::chrono::duration_cast(now - started_at).count(); + return json::object{ + {"status", "ok"}, + {"service", "confs"}, + {"config_dir", store->root_string()}, + {"cached", (std::int64_t)store->cached_count()}, + {"uptime", uptime} + }; + }); +} + +} diff --git a/tests/blackbox/test_core_confs.py b/tests/blackbox/test_core_confs.py new file mode 100644 index 0000000..1228ec4 --- /dev/null +++ b/tests/blackbox/test_core_confs.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""core 内嵌 confs 黑盒测试 — 与 Python confs 行为对比 + +前置条件: + - Python confs 运行于 :8010(基线) + - core (nidus.out) 以 --confs 8012 启动 + +用法: python3 test_core_confs.py [core_port] [py_port] +""" +import os +import sys + +sys.path.insert(0, '/opt/micro_servers/pymsg') +from pymsg.rpc import RpcClient, RpcError + +CORE_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8012 +PY_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8010 +CONFIG_DIR = '/opt/micro_servers/confs/configs' + + +def call(port, route, payload=None): + return RpcClient('127.0.0.1', port, 'test').call(route, payload) + + +def call_err(port, route, payload=None): + """返回错误信息字符串;若未报错则返回 None""" + try: + call(port, route, payload) + return None + except RpcError as e: + return str(e) + + +passed = failed = 0 + + +def check(label, cond): + global passed, failed + if cond: + passed += 1 + print(f' [PASS] {label}') + else: + failed += 1 + print(f' [FAIL] {label}') + + +def main(): + print('========== 1. health ==========') + h = call(CORE_PORT, 'health') + print(' core:', h) + check('status=ok', h['status'] == 'ok') + check('service=confs', h['service'] == 'confs') + check('config_dir 指向 confs/configs', h['config_dir'].endswith('confs/configs')) + + print('========== 2. list ==========') + l2, l0 = call(CORE_PORT, 'list'), call(PY_PORT, 'list') + n2 = sorted(c['name'] for c in l2['configs']) + n0 = sorted(c['name'] for c in l0['configs']) + print(' core:', n2) + check('文件清单一致', n2 == n0) + check('size 一致', + {c['name']: c['size'] for c in l2['configs']} + == {c['name']: c['size'] for c in l0['configs']}) + + print('========== 3. get 全文 (db.ini) ==========') + g2 = call(CORE_PORT, 'get', {'name': 'db.ini'}) + g0 = call(PY_PORT, 'get', {'name': 'db.ini'}) + check('content 一致', g2['content'] == g0['content']) + check('size 一致', g2['size'] == g0['size']) + + print('========== 4. get INI 键 ==========') + import configparser + cp = configparser.ConfigParser(interpolation=None) + cp.read(os.path.join(CONFIG_DIR, 'db.ini')) + sec = cp.sections()[0] + opt = list(cp[sec].keys())[0] + key = f'{sec}.{opt}' + k2 = call(CORE_PORT, 'get', {'name': 'db.ini', 'key': key}) + k0 = call(PY_PORT, 'get', {'name': 'db.ini', 'key': key}) + print(f' key={key} core={k2["value"]!r} py={k0["value"]!r}') + check('INI 键值一致', k2['value'] == k0['value']) + + print('========== 5. 错误用例(两边都应报错且信息一致) ==========') + for label, payload in [('不存在的配置', {'name': 'nope.ini'}), + ('路径穿越', {'name': '../etc/x.ini'}), + ('非法类型', {'name': 'x.exe'}), + ('缺 name', None), + ('键不存在', {'name': 'db.ini', 'key': 'no.such'})]: + e2 = call_err(CORE_PORT, 'get', payload) + e0 = call_err(PY_PORT, 'get', payload) + print(f' {label}: core={e2!r} py={e0!r}') + check(f'{label} 错误一致', e2 is not None and e2 == e0) + + print('========== 6. reload ==========') + r2 = call(CORE_PORT, 'reload', {'name': 'db.ini'}) + r0 = call(PY_PORT, 'reload', {'name': 'db.ini'}) + check('reload 单个', r2 == r0 == {'reloaded': ['db.ini']}) + r2, r0 = call(CORE_PORT, 'reload'), call(PY_PORT, 'reload') + check('reload 全部返回集合一致', sorted(r2['reloaded']) == sorted(r0['reloaded'])) + + print('========== 7. INI option 大小写不敏感 ==========') + ku2 = call(CORE_PORT, 'get', {'name': 'db.ini', 'key': f'{sec}.{opt.upper()}'}) + check('大写 option 取值一致', ku2['value'] == k0['value']) + + print('========== 8. JSON 配置(若存在) ==========') + jsons = [f for f in os.listdir(CONFIG_DIR) if f.endswith('.json')] + if jsons: + j2 = call(CORE_PORT, 'get', {'name': jsons[0]}) + j0 = call(PY_PORT, 'get', {'name': jsons[0]}) + check(f'{jsons[0]} content 一致', j2['content'] == j0['content']) + else: + print(' (无 .json 配置,跳过)') + + print() + print(f'结果: {passed} 通过, {failed} 失败') + return 1 if failed else 0 + + +if __name__ == '__main__': + sys.exit(main()) From b634f192eacc1cd2c539f862d7bf89348a2ca9e4 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 00:03:06 +0800 Subject: [PATCH 006/196] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20Blackbird?= =?UTF-8?q?=20=E6=9A=96=E8=89=B2=E8=B0=83=20Logo=20=E5=B9=B6=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E6=A0=B9=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出 logo.png(2x PNG,金铜渐变徽章 + 几何黑鸟) - 副标改为 "Powered by UniServe Framework" - README 标题下方居中嵌入 Logo --- README.md | 4 ++++ logo.png | Bin 0 -> 115329 bytes 2 files changed, 4 insertions(+) create mode 100644 logo.png diff --git a/README.md b/README.md index 218b361..6a1ae0d 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # blackbird # micro_servers +

+ blackbird · Powered by UniServe Framework +

+ 一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。 `core` 作为进程托管服务负责拉起并守护各业务子进程,`redirect80` 将 80 端口流量重定向到 443,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`ca` 作为中央认证服务统一鉴权(waf → ca → gateway 前置拓扑),`gateway` 退化为纯转发反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。`db2` / `dnfs2` 分别为 `db` / `dnfs` 的 C++20 重实现,接口完全兼容可平替。此外包含 aibox 的终端前端 `BotOps`、独立的机器人硬件控制模块 `bot4cj`、嵌入式帧缓冲图形界面 `lci` 和嵌入式脚本引擎 `libtry`。 diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0a8f90236bb761f33b376df0e4ba0b018a05f9ed GIT binary patch literal 115329 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�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 z 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 Date: Mon, 10 Aug 2026 00:56:21 +0800 Subject: [PATCH 007/196] =?UTF-8?q?docs(security):=20=E5=90=88=E5=B9=B6=20?= =?UTF-8?q?gateway=20=E5=AE=A1=E8=AE=A1=E6=96=87=E6=A1=A3=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=20ca=20=E5=AE=A1=E8=AE=A1=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三份 gateway 审计(20260801/0807/0809)合并为单一 docs/security/gateway_SECURITY_AUDIT.md - 已完成修复已合入 gateway/src/main.cpp 注释,认证相关项标注迁 ca - 新建 docs/security/ca_SECURITY_AUDIT.md,同步迁入 P2-3/P1-7/A2 等开放项 - 更新 docs/INDEX.md 审计表、ca/README.md 引用、auth_service_plan/libhttp 文档链接 Co-Authored-By: WorkBuddy --- ca/README.md | 4 +- docs/INDEX.md | 8 +- docs/reports/auth_service_plan.md | 2 +- docs/reports/libhttp_remote_addr_dev_doc.md | 2 +- docs/security/ca_SECURITY_AUDIT.md | 64 ++++ docs/security/gateway_SECURITY_AUDIT.md | 110 ++++++ .../gateway_SECURITY_AUDIT_20260801.md | 142 -------- .../gateway_SECURITY_AUDIT_20260807.md | 318 ------------------ .../gateway_SECURITY_AUDIT_20260809.md | 265 --------------- gateway/src/main.cpp | 4 +- 10 files changed, 185 insertions(+), 734 deletions(-) create mode 100644 docs/security/ca_SECURITY_AUDIT.md create mode 100644 docs/security/gateway_SECURITY_AUDIT.md delete mode 100644 docs/security/gateway_SECURITY_AUDIT_20260801.md delete mode 100644 docs/security/gateway_SECURITY_AUDIT_20260807.md delete mode 100644 docs/security/gateway_SECURITY_AUDIT_20260809.md diff --git a/ca/README.md b/ca/README.md index 2fb1bbb..e0af986 100644 --- a/ca/README.md +++ b/ca/README.md @@ -25,7 +25,7 @@ UniServe Framework 的中央认证服务(Central Auth),C++20 实现,v1.0 - **CAPTCHA 代理**:`GET /captcha/image` 代理 captcha2 签发图形验证码(PNG + `X-Captcha-Token` 响应头,10 次/分钟/IP,禁缓存)。 - **邮箱验证码**:`/register/send_code`、`/reset/send_code` 发送 6 位验证码邮件(10 分钟有效);`/reset_password` 重置密码(防枚举恒成功 + 改密后版本号吊销全端 Token)。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 -- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(见审计 P2-3)。 +- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(见 [`ca_SECURITY_AUDIT.md`](../docs/security/ca_SECURITY_AUDIT.md) P2-3)。 ## 身份头契约(strip-then-set) @@ -107,7 +107,7 @@ Target-Url: http://127.0.0.1:8006 // 可选;携带即视为 - `Upstream.gateway`:唯一上游 gateway 地址(默认 `http://127.0.0.1:8445`,env `UPSTREAM_GATEWAY`)。 - `Token.*`:`keep_time` / `refresh_grace_multiplier` / `max_refresh`。 - `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 -- `CORS.allowed_origins`:已读取但尚未用于预检响应(审计 P2-3)。 +- `CORS.allowed_origins`:已读取但尚未用于预检响应(见 [`ca_SECURITY_AUDIT.md`](../docs/security/ca_SECURITY_AUDIT.md) P2-3)。 - `WsProxy.routes`:`/path=ws://host:port/target`,分号分隔。 - `Downstream.*`:db/captcha/smtp 地址(env `DB_*`/`CAPTCHA_*`/`SMTP_*`/`CONFS_*`)。 diff --git a/docs/INDEX.md b/docs/INDEX.md index b3a4c72..c4a40df 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -9,7 +9,7 @@ docs/ ├── INDEX.md # 本文件(文档总索引) ├── security/ # 安全审计与加固(29 篇) ├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(13 篇) +└── reports/ # 分析报告与开发规划(14 篇) ``` --- @@ -37,9 +37,8 @@ docs/ | 模块 | 文档 | 审计范围 | |------|------|----------| | waf | [waf_SECURITY_AUDIT_20260801.md](security/waf_SECURITY_AUDIT_20260801.md) | Go WAF 网关(TLS、coraza、反向代理) | -| gateway | [gateway_SECURITY_AUDIT_20260801.md](security/gateway_SECURITY_AUDIT_20260801.md) | C++20 应用网关(鉴权、Token、反向代理)初版 | -| gateway | [gateway_SECURITY_AUDIT_20260807.md](security/gateway_SECURITY_AUDIT_20260807.md) | C++20 应用网关复审版(P0全部修复,进程退出bug修复) | -| gateway | [gateway_SECURITY_AUDIT_20260809.md](security/gateway_SECURITY_AUDIT_20260809.md) | C++20 应用网关增量复审(8 项新发现:关闭崩溃、头名大小写架空XFF修复等;黑盒测试重写落地) | +| gateway | [gateway_SECURITY_AUDIT.md](security/gateway_SECURITY_AUDIT.md) | C++20 应用网关安全审计合并文档(v0.8.0 三轮审计合并:初版/复审/增量复审;v0.9.0 纯转发,认证迁 ca) | +| ca | [ca_SECURITY_AUDIT.md](security/ca_SECURITY_AUDIT.md) | C++20 中央认证服务(Central Auth,v1.0.0),承接原 gateway 认证职责;含迁移接收项与 ca 相关开放项 | | core | [core_SECURITY_AUDIT_20260801.md](security/core_SECURITY_AUDIT_20260801.md) | C++20 进程托管服务(PMC/Nidus) | | db | [db_SECURITY_AUDIT_20260801.md](security/db_SECURITY_AUDIT_20260801.md) | Python 数据库服务(pymsg、SQLAlchemy、日志收集) | | db2 | [db2_SECURITY_AUDIT_20260806.md](security/db2_SECURITY_AUDIT_20260806.md) | C++20 数据库 RPC 服务(db 的 C++ 重实现,SQLite C API) | @@ -93,6 +92,7 @@ docs/ | [smtp_cpp_rewrite_dev_doc.md](reports/smtp_cpp_rewrite_dev_doc.md) | smtp 模块 C++20 重写开发方案(SMTP_SSL 客户端手写,审计 7 项遗留一并在重写中修复,零新增系统依赖) | | [libhttp_remote_addr_dev_doc.md](reports/libhttp_remote_addr_dev_doc.md) | libhttp 对端地址透传开发方案(X-Remote-Addr 可信头注入,非破坏;gateway trust_xff 配套,闭环审计 A4) | | [confs2_cpp_rewrite_dev_doc.md](reports/confs2_cpp_rewrite_dev_doc.md) | confs 模块 C++20 重写开发方案(libmsg RpcServer 替代 pymsg,协议兼容零改动,审计 7 项遗留一并修复,零新增系统依赖,未来可合入 core 注册中心) | +| [fsys_public_frontend_research_20260810.md](reports/fsys_public_frontend_research_20260810.md) | fsys/public 前端研究报告(Vanilla 技术栈、HttpOnly Cookie 认证体系、dnfs/aibox/view/mgmt 页面分析、8 项问题与改进建议) | --- diff --git a/docs/reports/auth_service_plan.md b/docs/reports/auth_service_plan.md index 2aead12..4b8c535 100644 --- a/docs/reports/auth_service_plan.md +++ b/docs/reports/auth_service_plan.md @@ -351,5 +351,5 @@ db_port = 8005 - 本文档**替代** 2026-08-08 初版计划书中的双栈拓扑与阶段划分(初版的职责盘点/迁移清单仍然有效,已并入 §1.2/§3.5) - [libhttp_remote_addr_dev_doc.md](libhttp_remote_addr_dev_doc.md)(A4 根治):实施时 `trust_xff`/`rate_limit_key()` 配套由 gateway 改为落 CA;`is_special_header` 两侧同步加 `x-remote-addr` -- [gateway_SECURITY_AUDIT_20260809.md](../security/gateway_SECURITY_AUDIT_20260809.md):A2(白名单 403)断言、P2-3(CORS 白名单)随转发层/配置迁入 CA;P1-7(CSRF Token)在 CA 层评估 +- [gateway_SECURITY_AUDIT.md](../security/gateway_SECURITY_AUDIT.md):A2(白名单 403)断言、P2-3(CORS 白名单)随转发层/配置迁入 CA;P1-7(CSRF Token)在 CA 层评估 - gateway README / 项目 README 的架构图、端口表、请求链路在上线步骤第 4 步同步更新 diff --git a/docs/reports/libhttp_remote_addr_dev_doc.md b/docs/reports/libhttp_remote_addr_dev_doc.md index b81fc13..3087db6 100644 --- a/docs/reports/libhttp_remote_addr_dev_doc.md +++ b/docs/reports/libhttp_remote_addr_dev_doc.md @@ -1,7 +1,7 @@ # libhttp 对端地址透传 · 开发文档 **日期**: 2026-08-09 -**背景**: gateway 审计 A4([gateway_SECURITY_AUDIT_20260809.md](../security/gateway_SECURITY_AUDIT_20260809.md))——独立部署模式 B 下 RateLimiter 以客户端可控的 `X-Forwarded-For` 为限流键,伪造 XFF 即绕过全部 IP 级限流。根治需要 libhttp 向 handler 透传 TCP 对端地址。 +**背景**: gateway 审计 A4([gateway_SECURITY_AUDIT.md](../security/gateway_SECURITY_AUDIT.md))——独立部署模式 B 下 RateLimiter 以客户端可控的 `X-Forwarded-For` 为限流键,伪造 XFF 即绕过全部 IP 级限流。根治需要 libhttp 向 handler 透传 TCP 对端地址。 **范围**: `libhttp`(注入实现)、`gateway`(消费方配套);其余消费方(core/dnfs2/fsys)零改动。 --- diff --git a/docs/security/ca_SECURITY_AUDIT.md b/docs/security/ca_SECURITY_AUDIT.md new file mode 100644 index 0000000..4812f3a --- /dev/null +++ b/docs/security/ca_SECURITY_AUDIT.md @@ -0,0 +1,64 @@ +# ca 模块安全审计索引(Central Auth) + +> ca(Central Auth,C++20,v1.0.0)是 UniServe Framework 的中央认证服务,承接原 gateway v0.8.0 的全部认证职责。 +> 本索引汇总:① 从 gateway v0.9.0 认证独立化**迁移接收**的审计项(已在 ca 闭环);② **ca 相关开放项**(与 gateway 转发层/配置共享,需 ca 侧处理);③ ca 原生安全态势基线。 +> 关联文档:gateway 合并审计 [`gateway_SECURITY_AUDIT.md`](gateway_SECURITY_AUDIT.md);认证迁移设计 [`../reports/auth_service_plan.md`](../reports/auth_service_plan.md)。 + +--- + +## 1. 从 gateway 迁移接收项(v0.9.0,已在 ca 闭环) + +下列历史问题随 v0.9.0 认证独立化由 gateway 移交 ca,gateway 侧不再跟踪。ca 已闭环(实现见 `ca/src/main.cpp`): + +| 原编号 | 问题 | ca 侧状态 | 实现位置 / 说明 | +|--------|------|-----------|----------------| +| P0-1 | RSA 密钥持久化 | 设计接受(重启即密钥轮换,同进程验签) | ca 内存生成 RSA 密钥 | +| P0-2 / P0-3 | 错误信息脱敏 | ✅ 已闭环 | ca handler 错误不外泄内部原因 | +| P1-1 | Token 吊销机制 | ✅ 已实现 | jti 黑名单(5000 阈值)+ 用户版本号双轨,纯内存 | +| P1-2 | 账户级登录失败锁定 | ✅ 已实现 | 登录失败计数 + 锁定(ca 侧) | +| P2-5 | JTI 黑名单内存有界 | ✅ 已实现 | 5000 条阈值清理 | +| P2-6 | 用户名 / 邮箱长度限制 | ✅ 已实现 | ca 输入校验 | +| A3 | 缺字段返回 400 | ✅ 已实现 | ca handler 参数校验 | +| A6 / A7 | 验证码双渠道统一 / 废弃内置验证码 | ✅ 已闭环 | 统一走 captcha2 远端校验 | + +--- + +## 2. ca 相关开放项(需 ca 侧处理) + +下列 gateway 合并审计的开放项与 ca 直接相关,归 ca 跟踪: + +| 编号 | 等级 | 问题 | 归属说明 | 状态 | +|------|------|------|----------|------| +| P2-3 | 🟡 | CORS 白名单未生效(OPTIONS 预检反射 Origin) | ca `CORS.allowed_origins` 已读取但未用于预检响应头(见 `ca/README.md` 配置段);与 gateway 共享同一白名单配置 | ❌ 未修复 | +| P1-7 | 🟠 | CSRF Token 缺失 | gateway 纯转发无认证表单;CSRF 评估在 ca 层(会话模型 + `SameSite=Lax`)。如 ca 启用 `tls=on` 独立 HTTPS 需评估 | ❌ 未修复(低优先级) | +| A2 | 🟠 | 白名单 403 断言 | `Target-Url` 白名单由 gateway `Proxy.opened` 裁决 403,但白名单配置 / 断言随转发层迁入 ca;跨模块边界,gateway 与 ca 需保持一致 | ⚠️ 边界共识(gateway A2 已转绿,ca 侧需保持白名单同步) | + +> 其余 gateway 开放项(P1-3 代理超时、P1-5 请求体大小、P1-8 日志注入、P2-2 TLS、P2-4 HTTP 超时、A4 XFF)属 gateway 纯转发层 / libhttp 层,不归 ca,见 [`gateway_SECURITY_AUDIT.md`](gateway_SECURITY_AUDIT.md) §4。 + +--- + +## 3. ca 原生安全态势基线 + +摘自 `ca/README.md` 安全特性(v1.0.0): + +| 特性 | 实现 | +|------|------| +| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16B 随机盐,32B 哈希 | +| 密码强度 | ≥8 字符,大写/小写/数字/特殊中至少 3 类 | +| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期(默认 1h) | +| Token 吊销 | 双轨纯内存:jti 黑名单(5000 阈值)+ 用户版本号 | +| 常数时间比较 | PBKDF2 验证 `diff |= hash[i] ^ expected[i]` 防 timing | +| 防枚举 | 不存在用户 dummy PBKDF2 拉平时序;重置密码恒成功 | +| CAPTCHA | 统一 captcha2 远端校验(一次性 Token);`/login` 强制 | +| TLS 可选 | `Server.tls = off` 纯 HTTP(配合 WAF),`on` 用 `full_chain.crt` / `server.key` | +| 下游容错 | pymsg `retry_on_refused` 阻塞等待 db/confs;日志 fail-safe | + +--- + +## 4. 维护说明 + +- 新增 / 复测 ca 相关开放项请编辑本文件 §2;修复后移至 §1 或合入 `ca/src/main.cpp` 注释。 +- gateway 合并审计的“已迁 ca”清单(§1)以本文件为权威;如 gateway 文档与本文件不一致,以本文件为准。 + +**审计人**: AI Security Auditor +**建立于**: 2026-08-10(从 gateway 合并审计同步开放项与迁移接收项;此前 ca 无独立审计文档) diff --git a/docs/security/gateway_SECURITY_AUDIT.md b/docs/security/gateway_SECURITY_AUDIT.md new file mode 100644 index 0000000..f0b7f4b --- /dev/null +++ b/docs/security/gateway_SECURITY_AUDIT.md @@ -0,0 +1,110 @@ +# gateway 模块安全审计(合并文档) + +> 本文档合并了 gateway 自 v0.8.0 以来的三轮安全审计记录: +> - **2026-08-01** 初版审计(gateway v0.8.0,2500+ 行,认证内嵌) +> - **2026-08-07** 复审(P0 全部修复、进程退出 bug 修复) +> - **2026-08-09** 增量复审(8 项新发现:优雅关闭崩溃、头名大小写、XFF 伪造绕过修复、黑盒测试重写落地) +> +> **架构变更(v0.9.0)**:gateway 已退化为**纯转发服务**(仅 loopback 监听,不验签),认证职责(Token 签发/验签、登录/注册/重置、限流、WS 代理鉴权、PBKDF2/RSA)整体迁移至 [ca](../ca/) 模块。 +> +> 所有 ✅ 已修复项已作为代码注释合入 `gateway/src/main.cpp`(以“审计 A*”“P1-1”等标记内联于对应代码)。本文档仅保留**开放项、历史追溯与上线检查清单**,避免陈旧信息。 + +--- + +## 1. 审计轮次概览 + +| 轮次 | 日期 | 基线 / 范围 | 关键结论 | +|------|------|-------------|----------| +| 初版 | 2026-08-01 | v0.8.0 `src/main.cpp`、`src/shared/`(libth/libhttp/libcrypt/libsqlite3) | 建立 P0/P1/P2 分级;P0-1 RSA 持久化定为设计接受;发现认证内嵌带来的 Token 吊销缺失等 | +| 复审 | 2026-08-07 | v0.9.0 前 `src/main.cpp`、`inc/`、配置 | P0 三项全部修复;进程退出 bug 修复;Token 吊销(P1-1)实现并随认证独立化迁 ca | +| 增量复审 | 2026-08-09 | v0.9.0 纯转发 `src/main.cpp` | 8 项新发现(A1/A2/A5/A8 等代理层修复、黑盒测试重写落地);认证类问题转入 ca 审计 | + +--- + +## 2. 已完成修复(已合入代码,供追溯) + +代码位置:`gateway/src/main.cpp`(注释以“审计 A*”/“P1-1”等标记)。 + +- **A1** 优雅关闭 `double-join` 崩溃(删除 else 分支重复 `join`) +- **A2** 白名单外 Target-Url 返回 **403**(改抛 `HttpException 2303`,不再 500) +- **A5** `is_special_header` 头名大小写归一(统一转小写比较) +- **A8** 错误消息 `[Http][Error NNNN]` 前缀剥离(仅 `e.message()` 外泄,前缀留服务端日志) +- **P1-1** 路径遍历防护(`is_safe_target`)+ XFF 剥离与可信注入(`is_special_header` / `handleProxy`) +- **libhttp P1-2** 反向代理响应体 10MB 上限 +- 信号安全重构(async-signal-safe 标志 + SIGPIPE 忽略 + 关闭 8s / 日志 3s 超时保护) +- 启动 loopback 强制自检 + `X-Auth-User` 存在性不变量(v0.9.0 新增) + +> 认证侧脱敏(P0-2 / P0-3 错误信息脱敏)已在 `wrapperHttp` / `ExpBase.hpp` 闭环。 + +--- + +## 3. 已随认证迁移至 ca(v0.9.0,不再属 gateway 待办) + +以下历史问题在 v0.9.0 认证独立化后于 ca 模块闭环,gateway 侧无需再处理;ca 的独立审计见 `ca_SECURITY_AUDIT.md`(其 §2 为 ca 相关开放项)。 + +- P0-1 RSA 密钥持久化(设计接受:重启即密钥轮换) +- P0-2 / P0-3 错误信息脱敏 +- P1-1 Token 吊销机制(jti 黑名单 + 用户版本号双轨,已实现于 ca) +- P1-2 账户级登录失败锁定(ca 侧) +- P2-5 JTI 黑名单内存有界(ca 侧) +- P2-6 用户名 / 邮箱长度限制(ca 侧) +- A3 缺字段返回 400(ca 侧 handler) +- A6 / A7 验证码双渠道统一 / 废弃内置验证码(ca + captcha2 侧) + +--- + +## 4. 当前开放项(gateway v0.9.0 纯转发) + +| 编号 | 等级 | 问题 | 状态 | 说明 / 修复建议 | +|------|------|------|------|------| +| P1-3 | 🟠 | 上游代理 `HttpClient` 未显式设置超时 | ⚠️ 部分修复 | 下游服务(captcha/smtp)超时已配;代理转发 `HttpClient` 未设连接/读取超时。建议连接 5s、读取 30s(`handleProxy` 出站调用处) | +| P1-5 | 🟠 | 请求体大小无显式限制 | ❌ 未修复 | `req.body()` 直接读取,gateway 层未检查 `Content-Length`;依赖 libhttp 默认 10MB。建议超 10MB 返回 413 | +| P1-7 | 🟠 | CSRF Token 缺失 | ❌ 未修复(低优先级) | gateway 纯转发、无认证表单;前端 + ca 会话模型覆盖。如启用独立 HTTPS 模式(`tls = on`)需评估 | +| P1-8 | 🟠 | 日志注入 | ⚠️ 部分修复 | 用户输入经 ca 透传后直接拼接日志;内部 pymsg→db,外部不可见,风险低。建议对 username/email 等字段做换行符转义 | +| P2-2 | 🟡 | TLS 版本 / 套件未限定 | ❌ 未修复(低风险) | `tls = off`,TLS 由 WAF 终止;如启用 `tls = on` 需增加 TLS 版本与 CipherSuites 配置项 | +| P2-3 | 🟡 | CORS 反射任意 Origin | ❌ 未修复 | `_CORS_ORIGINS` 白名单已加载但未用于响应头;OPTIONS 预检由 libhttp 直接反射 Origin。需 libhttp 层接入白名单或由 gateway 注册显式 OPTIONS 路由 | +| P2-4 | 🟡 | HTTP 服务器无 Read/Write/Idle 超时 | ⚠️ 部分修复 | `HttpServer(addr,port,4)` 的 `4` 为连接超时,无请求级超时;存在 Slowloris / 连接耗尽风险 | +| A4 | 🟡 | 模式 B 独立部署限流可被 XFF 伪造绕过 | 📝 已文档化 | README 模式 B 已加安全降级提示(伪造 XFF 可绕过限流,生产必须用模式 A / WAF 前置)。根因需 libhttp 向 handler 透传 TCP 对端地址(跨模块 API 变更,见 `../reports/libhttp_remote_addr_dev_doc.md`) | + +> **不在 gateway 范围(libhttp 层,gateway 侧跳过)**:P1-4 C++ HTTP 解析器 fuzz(属 libhttp);P1-6 反向代理响应大小已由 libhttp 默认 10MB 保证(视为已闭环)。 + +--- + +## 5. 配置安全审计(历史结论,仍适用) + +| 检查项 | 状态 | 说明 | +|--------|------|------| +| 监听地址 | ✅ 安全 | `address = 127.0.0.1` | +| TLS 模式 | ✅ 符合部署 | `tls = off`,WAF 终止 | +| 管理员白名单 | ✅ 安全 | `users` 显式配置,无默认管理员 | +| CORS 白名单 | ⚠️ 配置未生效 | `allowed_origins` 已配但未用于响应头(见 P2-3) | +| Token 有效期 | ✅ 合理 | `keep_time = 3600`,`max_refresh = 10` | +| WS 代理路由 | ✅ 安全 | 仅 `/ws/aibox`,需管理员权限 | +| 敏感凭据 | ✅ 无泄露 | 配置文件无硬编码密码 / 密钥 | + +--- + +## 6. 黑盒测试待办(需测试钩子) + +- 完整旅程(captcha → send_code → register → login → whoami → logout)、Cookie 链路(Set-Cookie 属性、代理注入 Authorization)、jti / 版本号吊销、refresh_token、白名单 403(A2 已转绿)、防枚举时序 +- `test_captcha.py` / `test_smtp.py` 重写为 pymsg 直连(captcha2:8009、smtp:8008) +- 钩子方案(二选一):captcha2 / smtp 提供测试模式(固定验证码答案);或 `gateway.ini` 增加 `[Security] captcha_required / email_code_required` 开关 + +--- + +## 7. 上线前检查清单 + +- [ ] 确认 WAF 规则已启用 +- [ ] 确认所有服务绑定 127.0.0.1 +- [ ] 确认云安全组 / ufw 仅放行 TCP 443 +- [ ] 确认无默认管理员账户 +- [ ] 确认 `.env` 权限 0600,SMTP 密码未硬编码 +- [ ] 确认 gateway 配置 `tls = off`、`address = 127.0.0.1` +- [ ] 回归测试:登录 / 注册 / 代理 / Token 刷新 / 吊销全链路正常 + +--- + +**维护说明**:新增 / 复测开放项请直接编辑本文件;修复后请将对应行移至“已完成”(§2)或合入 `gateway/src/main.cpp` 代码注释后删除本行。 + +**审计人**: AI Security Auditor +**合并于**: 2026-08-10(三轮审计合并;源文件 `gateway_SECURITY_AUDIT_20260801.md` / `_20260807.md` / `_20260809.md` 已归档删除) diff --git a/docs/security/gateway_SECURITY_AUDIT_20260801.md b/docs/security/gateway_SECURITY_AUDIT_20260801.md deleted file mode 100644 index 69daa87..0000000 --- a/docs/security/gateway_SECURITY_AUDIT_20260801.md +++ /dev/null @@ -1,142 +0,0 @@ -# gateway 模块安全审计报告 - -**审计日期**: 2026-08-01 -**审计范围**: `src/main.cpp`、`src/shared/`(libth、libhttp、libcrypt、libsqlite3) -**代码版本**: dev_qing 分支 - ---- - -## 修复进度 - -| 优先级 | 总数 | 已修复 | 部分修复 | 未修复 | -|--------|------|--------|----------|--------| -| P0 | 1 | 0 | 1 | 0 | -| P1 | 6 | 0 | 1 | 5 | -| P2 | 4 | 1 | 0 | 3 | -| **合计** | **11** | **1** | **2** | **8** | - ---- - -## 🔴 P0 — 严重问题 - -### P0-1: RSA 私钥存储在仓库中/硬编码 -- **位置**: `main.cpp` L686 `_KEYGEN = std::make_unique();` -- **当前状态**: ⚠️ 部分修复 - - 密钥由 `MyRSA::Generator` 在内存中运行时生成,**未硬编码在源码中**,**未随仓库分发** - - 每个进程实例独立生成密钥对,满足"每个部署独立密钥"要求 - - **遗留问题**: 密钥为内存生成,进程重启后丢失,所有已签发 Token 立即失效 -- **修复建议**: - 1. ~~私钥从文件路径加载(启动参数或环境变量),不硬编码~~ ✅ 已不在源码中硬编码 - 2. ~~每个部署实例生成独立密钥对~~ ✅ 已满足 - 3. 私钥文件权限 0600,仅服务运行用户可读 — 需增加持久化机制 - 4. 公钥分发机制:dnfs、aibox 等需要验签的服务可访问公钥 — 待实现 - ---- - -## 🟠 P1 — 高危问题 - -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P1-1 | Token 吊销机制缺失 | ❌ 未修复 | `handleLogout` (L1195) 仅清除 Cookie,无服务端黑名单/吊销列表;修改密码/注销后旧 Token 仍有效直到过期 | -| P1-2 | 登录失败次数限制 | ⚠️ 部分修复 | IP 级限流已实现 (`_LOGIN_RATE` L376,`check_and_record(ip,5,300)` L1033),但**无账户级锁定**(同一邮箱多次失败不触发账户锁定) | -| P1-3 | 上游代理超时配置 | ❌ 未修复 | `handleProxy` (L2084) 中 `HttpClient` 未显式设置超时,依赖 libhttp 默认值 | -| P1-4 | C++ HTTP 解析器缓冲区溢出审计 | ❌ 未修复 | libhttp 库(`src/shared/`)需全面 fuzz 测试 | -| P1-5 | 请求体大小限制 | ❌ 未修复 | 未配置显式请求体大小上限(如 10MB),依赖 Boost.Beast 默认行为 | -| P1-6 | 反向代理响应无大小限制 | ❌ 未修复 | `handleProxy` 回调 L2212-2214 拼接下游响应 body 无上限(`res.body() += ret`,带 `FIXME: 增加长度限制`),下游(static/dnfs)返回超大响应可导致 OOM(全局审计 P1-1) | - ---- - -## 🟡 P2 — 中危问题 - -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P2-1 | 错误页面泄露版本号/堆栈信息 | ✅ 已修复 | `wrapperHttp` (L920-965) 异常捕获返回统一 `"Internal Server Error"`;`ExceptionBase::build_message()` 中文件/行号/函数名已注释(`ExpBase.hpp` L173-197),`what()` 仅含业务消息,不向客户端暴露源码位置 | -| P2-2 | HTTPS TLS 版本和密码套件安全 | ❌ 未修复 | `main.cpp` L682 TLS 模式下仅指定证书文件,未显式限定 TLS 版本和 CipherSuites | -| P2-3 | CORS 通配(反射任意 Origin) | ❌ 未修复 | **根因**:`_CORS_ORIGINS` 白名单 (L598) 已读取但**从未用于响应头**——OPTIONS 预检由 libhttp 库处理(`HttpServer_Request.cpp` L73-74),直接反射请求 Origin,libhttp 不感知 gateway 的 `_CORS_ORIGINS` 状态,等于允许任意来源(全局审计 P2)。修复需在 libhttp 层接入白名单或由 gateway 注册显式 OPTIONS 路由 | -| P2-4 | HTTP 服务器无超时配置 | ❌ 未修复 | 未设置 Read/Write/Idle 超时,`HttpServer(addr,port,threads)` 构造函数无超时参数,依赖 libhttp 默认行为,存在 Slowloris/连接耗尽风险(全局审计 P2) | - ---- - -## ✅ 已有安全机制 - -1. ✅ **PBKDF2 密码哈希**: 慢哈希算法防彩虹表,使用唯一盐值 (`main.cpp` L58-143) -2. ✅ **RSA Token 签名**: Token 使用 RSA 私钥签名,防伪造 (`main.cpp` L1112-1130) -3. ✅ **反向代理架构**: 统一入口,隐藏后端服务拓扑 -4. ✅ **速率限制**: 多层防护(IP 级 + 邮箱级双窗口限流、60秒冷却、失败锁定) -5. ✅ **防用户名枚举**: 用户不存在时执行 dummy PBKDF2 校验拉平响应时序 (`main.cpp` L1113-1118) -6. ✅ **WAF 前置防护**: Go WAF 处理 SQLi/XSS/CC/协议解析 -7. ✅ **C++ 高性能实现**: 避免 Python GIL 瓶颈,抗并发能力强 -8. ✅ **依赖审计**: 使用项目自带的 libcrypt 加密库(PBKDF2、RSA) -9. ✅ **路径遍历防护**: `is_safe_target()` 校验请求路径,拒绝 `..`、空字节、URL 编码遍历 (`main.cpp` L2301-2326) -10. ✅ **X-Forwarded-For 可信注入**: 剥离客户端伪造的 IP 头后由 gateway 主动设置 (`main.cpp` L2229, `is_special_header` L2266-2280) -11. ✅ **优雅关闭**: SIGINT/SIGTERM 信号处理 (`main.cpp` L482-496) -12. ✅ **Token 刷新机制**: 支持宽限期刷新 + 次数上限 + 限流 (`main.cpp` L1463-1590) -13. ✅ **CAPTCHA 强制校验**: 登录/注册/发送验证码均需 CAPTCHA,对接 captcha 服务 (`main.cpp` L443, 调用点 L1048-1055/1707-1713/1801-1810/1915-1923) -14. ✅ **异步日志**: 批量推送 + fail-safe,db 不可用时静默丢弃 (`LogC.hpp`) - ---- - -## 修复路线图 - -### 立即(24小时) -- [ ] 增加请求体大小限制(参考 waf 10MB 配置) -- [ ] 反向代理响应体大小限制(P1-6,防 OOM) -- [ ] 代理上游 HttpClient 增加显式超时配置 - -### 短期(72小时) -- [x] 确认 RSA 私钥未硬编码 → ✅ 已确认:运行时内存生成,非硬编码 -- [ ] 实现 Token 吊销列表(密码修改时注销旧 Token) -- [ ] 增加账户级登录失败锁定 -- [ ] libhttp 解析器 fuzz 测试 - -### 中期 -- [x] 错误页面脱敏 → ✅ 已完成 -- [ ] TLS 安全配置(限定 TLS 版本和 CipherSuites) -- [ ] CORS 白名单生效(`_CORS_ORIGINS` 接入响应头,替代反射任意 Origin) -- [ ] HTTP 服务器超时配置(Read/Write/Idle) -- [ ] 管理操作审计日志 -- [ ] RSA 密钥持久化(避免重启后 Token 全部失效) - ---- - -## 代码验证记录 - -**验证日期**: 2026-08-03 -**验证方式**: 逐条对照审计报告与当前代码(`main.cpp`、`Conf.hpp`、`DbC.hpp`、`LogC.hpp`、`Msg.hpp`) - -### 验证结果汇总 - -| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | -|------|------|--------|----------|--------|--------| -| P0 | 1 | 0 | 1 | 0 | 50% (部分) | -| P1 | 6 | 0 | 1 | 5 | 8% (部分) | -| P2 | 4 | 1 | 0 | 3 | 25% | -| **合计** | **11** | **1** | **2** | **8** | **18%** | - -### 逐条验证 - -| 编号 | 状态 | 代码位置 | 验证说明 | -|------|------|----------|----------| -| P0-1 | ⚠️ 部分 | `main.cpp` L686 | `MyRSA::Generator()` 运行时生成,非硬编码;但无持久化,重启后 Token 全部失效 | -| P1-1 | ❌ 未修复 | `main.cpp` L1195-1209 | `handleLogout` 仅清除 Cookie,无 Token 黑名单/吊销机制 | -| P1-2 | ⚠️ 部分 | `main.cpp` L376, L1033 | IP 限流已实现 (5次/300s),无账户级锁定 | -| P1-3 | ❌ 未修复 | `main.cpp` L2084-2262 | `handleProxy` 中 `HttpClient` 未显式设置超时 | -| P1-4 | ❌ 未修复 | `src/shared/` | libhttp 库未进行 fuzz 测试 | -| P1-5 | ❌ 未修复 | `main.cpp` | 未设置请求体大小上限 | -| P1-6 | ❌ 未修复 | `main.cpp` L2212-2214 | `handleProxy` 回调 `res.body() += ret` 拼接下游响应 body 无上限,超大响应可 OOM | -| P2-1 | ✅ 已修复 | `main.cpp` L920-965, `ExpBase.hpp` L173-197 | 异常统一返回 `"Internal Server Error"`;`build_message()` 已注释文件/行号/函数名,不泄露源码位置 | -| P2-2 | ❌ 未修复 | `main.cpp` L682 | TLS 模式仅指定证书,未限定版本和密码套件 | -| P2-3 | ❌ 未修复 | `main.cpp` L598, libhttp `HttpServer_Request.cpp` L73-74 | `_CORS_ORIGINS` 白名单读取但未用于响应头(OPTIONS 由 libhttp 处理,不感知 gateway 状态);OPTIONS 预检反射任意 Origin | -| P2-4 | ❌ 未修复 | `main.cpp` L677-679, libhttp `HttpServer` | `HttpServer(addr,port,threads)` 无超时参数,未设置 HTTP 服务器 Read/Write/Idle 超时 | - -### 新增安全机制(审计报告未覆盖) - -| 机制 | 代码位置 | 说明 | -|------|----------|------| -| 路径遍历防护 | `main.cpp` L2301-2326 | `is_safe_target()` 拒绝 `..`、空字节、URL 编码遍历 | -| XFF 可信注入 | `main.cpp` L2229, L2266-2280 | `is_special_header` 剥离客户端伪造 IP 头后主动设置可信 XFF | -| 优雅关闭 | `main.cpp` L482-496 | SIGINT/SIGTERM 信号处理 | -| 防用户枚举 | `main.cpp` L1113-1118 | 用户不存在时执行 dummy PBKDF2 拉平响应时序 | -| 密码强度校验 | `main.cpp` L146-180 | 8-128字符,至少3种字符类别 | -| 邮箱双窗口限流 | `main.cpp` L1814-1818 | 3次/小时 + 10次/天/邮箱 | -| 异步日志 fail-safe | `LogC.hpp` L210-213 | db 不可用时静默丢弃,绝不影响业务 | diff --git a/docs/security/gateway_SECURITY_AUDIT_20260807.md b/docs/security/gateway_SECURITY_AUDIT_20260807.md deleted file mode 100644 index 31c66d7..0000000 --- a/docs/security/gateway_SECURITY_AUDIT_20260807.md +++ /dev/null @@ -1,318 +0,0 @@ -# gateway 模块安全审计报告(更新版) - -**审计日期**: 2026-08-07 -**审计范围**: `src/main.cpp`、`inc/` 头文件、配置文件 -**代码版本**: dev_qing 分支 -**基于版本**: 2026-08-01 初版审计,2026-08-07 复审 - ---- - -## 修复进度总览 - -| 优先级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | -|--------|------|--------|----------|--------|--------| -| P0 | 3 | 3 | 0 | 0 | 100% | -| P1 | 8 | 3 | 3 | 2 | 56% | -| P2 | 6 | 1 | 2 | 3 | 33% | -| **合计** | **17** | **7** | **5** | **5** | **56%** | - -> 对比 2026-08-01 版本:修复率从 18% 提升至 56%,**所有P0严重问题已全部修复**,核心安全机制(Token吊销、错误信息脱敏)已落地。 -> -> **更新日志**: -> - 2026-08-07 P0修复:完成P0-3修复,将4处暴露内部步骤的错误消息替换为用户友好文案,修正拼写错误和状态码错误。 -> - 2026-08-07 稳定性修复:修复进程无法正常退出("杀不死")问题,包括信号处理安全、Listener无限递归CPU 100%、关闭超时保护等多个关键bug。 - ---- - -## 🔴 P0 — 严重问题 - -### P0-1: RSA 私钥持久化问题 — ✅ 已修复(设计接受) - -- **位置**: `main.cpp` L967 `_KEYGEN = std::make_unique();` -- **当前状态**: ✅ 已修复(按设计接受) -- **修复说明**: - - 密钥由 `MyRSA::Generator` 在内存中运行时生成,**未硬编码在源码中** - - 每个进程实例独立生成密钥对,满足"每个部署独立密钥"要求 - - **设计决策**: 进程重启后 RSA 密钥对重新生成,旧 Token 因 RSA 验签失败而自然失效,这是一种有效的隐式密钥轮换机制 - - 配合 Token 版本号机制(`_USER_VER_MAP`),重启等同于全量 Token 吊销,安全风险可控 -- **备注**: 如需持久化密钥(避免重启后用户需重新登录),可后续从文件加载;当前设计在安全性上更优(重启即吊销)。 - -### P0-2(新增): 错误页面泄露版本号/堆栈信息 — ✅ 已修复 - -- **原编号**: P2-1(初版),升级确认后确认核心问题已解决 -- **位置**: `main.cpp` L1167-1213、`ExpBase.hpp` L173-197 -- **当前状态**: ✅ 已修复 -- **修复说明**: - - `wrapperHttp` 中 `std::exception` 捕获分支已不再返回 C++ 类型名和 `what()` 详情,统一返回 `"Internal Server Error"` - - `ExceptionBase::build_message()` 中源码文件路径、行号、函数名、嵌套异常等位置信息已全部注释,`what()` 仅返回错误码和业务消息 - - 完整错误详情仍通过 `std::cerr` 输出到服务端日志,并写入 db `Logs` 表供排障 - - 服务端日志记录完整异常链,不影响排障能力 - -### P0-3(新增,原初版P2-1延伸): HttpException 业务消息返回 — ✅ 已修复 - -- **位置**: `main.cpp` L1179-1181, L1318, L1381, L1882, L2205 -- **当前状态**: ✅ 已修复(2026-08-07) -- **修复说明**: - - `HttpException` 被捕获后,`e.what()` 仍写入客户端 JSON 响应的 `message` 字段,但已确保不暴露敏感信息 - - **已解决**: 不再返回源码路径、行号、C++ 类型签名等敏感信息(P0-2已修复) - - **已修复**: 4处暴露内部步骤的错误消息已全部替换为用户友好文案: - - `"Login (Query Step) Failed"` → `"Login failed, please try again later"` - - `"Login (Update Step) Failed"` → `"Login failed, please try again later"` - - `"Registe Failed"` (拼写错误+400) → `"Registration failed, please try again later"` (修正为500) - - `"Reset Failed"` → `"Password reset failed, please try again later"` - - 详细错误信息仍记录在服务端日志(stderr + db Logs表),不影响排障 -- **修复验证**: 所有500错误消息均为用户友好的通用文案,不再暴露内部实现步骤 - ---- - -## 🟠 P1 — 高危问题 - -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P1-1 | Token 吊销机制缺失 | ✅ 已修复 | jti 黑名单 + 用户版本号双轨机制已完整实现(L538-754) | -| P1-2 | 登录失败次数限制 | ⚠️ 部分修复 | IP 级限流已实现(5次/300s/IP),**无账户级锁定** | -| P1-3 | 上游代理超时配置 | ⚠️ 部分修复 | 下游服务(captcha 5s/smtp 5s/smtp send 20s)超时已配置,代理转发 HttpClient 未显式设置超时 | -| P1-4 | C++ HTTP 解析器缓冲区溢出审计 | ⏭️ 跳过 | 属于 libhttp 层审计范围,gateway 侧无法直接修复;libhttp 80% 问题已修复 | -| P1-5 | 请求体大小限制 | ❌ 未修复 | `req.body()` 直接读取(L2341),main.cpp 层未显式限制;依赖 libhttp 默认 10MB 限制 | -| P1-6 | 反向代理响应无大小限制 | ✅ 已修复 | 注释 L2302 说明:响应体大小限制由 HttpClient 默认 10MB 上限保证(libhttp P1-2 已修复) | -| P1-7(新增) | CSRF 防护不足 | ❌ 未修复 | Cookie 设置 `SameSite=Lax`,但所有 POST 接口(`/login`、`/register`、`/reset_password`、`/logout`)缺少 CSRF Token 验证 | -| P1-8(新增) | 日志注入风险 | ⚠️ 部分修复 | 用户名/邮箱直接拼接写入日志消息(如 L1429、L1885、L2111);日志通过 pymsg 写入内部 db,外部无法直接查看,风险较低 | - ---- - -## 🟡 P2 — 中危问题 - -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P2-2 | HTTPS TLS 版本和密码套件安全 | ❌ 未修复 | TLS 模式仅指定证书路径和超时参数(L963),未显式限定 TLS 版本和 CipherSuites;当前配置 `tls = off`,TLS 由 WAF 终止,实际部署风险低 | -| P2-3 | CORS 通配(反射任意 Origin) | ❌ 未修复 | `_CORS_ORIGINS` 白名单已从配置加载(L879),但 `handleProxy()` 中未应用 CORS 响应头;CORS 预检由 libhttp 直接反射请求 Origin | -| P2-4 | HTTP 服务器无超时配置 | ⚠️ 部分修复 | HttpServer 构造函数传入 `4` 作为参数(L963、L965),为连接超时;未配置 Read/Write/Idle 请求级超时 | -| P2-5(新增) | JTI 黑名单内存无界风险 | ✅ 已修复 | `_JTI_DENYLIST_MAX_SIZE = 5000`(L461),超过阈值时批量清理过期条目;RSA 密钥重启重新生成,天然限制黑名单生命周期 | -| P2-6(新增) | 用户名输入未做长度限制 | ⚠️ 部分修复 | 密码有 8-128 字符长度校验(L148-181),但用户名/邮箱未做显式长度限制;db 层字段长度约束作为兜底 | - ---- - -## ✅ 已验证的安全机制(完整列表) - -### 核心安全能力 - -| 机制 | 位置 | 状态 | 说明 | -|------|------|------|------| -| PBKDF2 密码哈希 | L58-143 | ✅ | PBKDF2-HMAC-SHA256,100,000 迭代,16字节随机盐,恒定时间比较 | -| 密码强度校验 | L148-181 | ✅ | 8-128字符,至少3类字符(大写/小写/数字/特殊) | -| RSA Token 签名 | L1385 | ✅ | Token 使用 RSA 私钥签名,防伪造 | -| 安全随机 JTI | L540-550 | ✅ | OpenSSL `RAND_bytes` 生成 128-bit 随机 JTI | -| **Token 吊销机制** | L538-754 | ✅ | jti 黑名单(单Token吊销)+ 用户版本号(全设备吊销)双轨机制 | -| jti 黑名单自动清理 | L569-578 | ✅ | 超过5000条目时批量清理过期条目,内存有界 | -| IP 级速率限制 | L186-227, L1271 | ✅ | 登录/注册/发码/刷新/重置均有独立 RateLimiter | -| 邮箱级双窗口限流 | L422-423, L1948-1955 | ✅ | 同一邮箱3次/小时 + 10次/天 | -| 60秒发码冷却 | L2090-2107 | ✅ | smtp 层返回 `retry_after`,gateway 透传 429 | -| CAPTCHA 强制校验 | L466-516 | ✅ | 登录/注册/发码/重置均强制校验,一次性消费 | -| **CAPTCHA fail-closed** | L500-516 | ✅ | captcha 服务不可用时一律视为校验失败 | -| 邮箱验证码 fail-closed | L520-536 | ✅ | smtp 服务不可用时一律视为校验失败 | -| 防用户名枚举 | L1351-1357 | ✅ | 用户不存在时执行 dummy PBKDF2 校验拉平响应时序 | -| 防重置密码枚举 | L2210-2212 | ✅ | 用户不存在时仍返回成功,不泄露邮箱是否注册 | -| SameSite Cookie | L1424, L1722 | ✅ | `SameSite=Lax` | -| HttpOnly + Secure Cookie | L1423-1426, L1720-1724 | ✅ | `HttpOnly; Secure; SameSite=Lax` | -| Token 生命周期控制 | L1570-1747 | ✅ | refresh_count 限制(最多10次)+ 宽限期刷新(2倍有效期) | -| 路径遍历防护 | L2392-2418 | ✅ | `is_safe_target()` 拒绝 `..`、空字节、URL编码遍历、非绝对路径 | -| 伪造 IP 头剥离 | L2357-2369 | ✅ | `is_special_header()` 剥离客户端伪造的 XFF/X-Real-IP/Forwarded | -| 可信 XFF 注入 | L2323-2329 | ✅ | gateway 主动设置可信 X-Forwarded-For,下游可信任 | -| 敏感头过滤 | L2357-2369 | ✅ | 过滤 Host/Connection/Accept-Encoding 等传输层头 | -| Authorization 自动注入 | L2331-2334 | ✅ | Cookie Token 自动转换为 `Authorization: Bearer` 向下游传递 | -| WebSocket 管理员鉴权 | L1140-1145 | ✅ | WS 代理握手阶段校验 Token,仅 `is_admin=true` 可连接 | -| 异常统一捕获 | L1158-1213 | ✅ | wrapperHttp 捕获所有异常,未处理异常返回 500 不崩溃 | -| 错误信息脱敏 | L1192-1213, ExpBase.hpp | ✅ | 500错误不再返回 C++ 类型名、源码路径、行号 | -| 异步日志 fail-safe | LogC.hpp | ✅ | 后台线程批量推送,db不可用时静默丢弃,不影响业务 | -| 优雅关闭 | L763-777 | ✅ | SIGINT/SIGTERM 信号处理,停止服务后 flush 日志 | -| 管理员白名单 | L1389 | ✅ | 登录时命中 `[Admin].users` 才签发 `is_admin=true` | -| 公网目标白名单 | L2286-2298 | ✅ | 代理目标必须在 `Proxy.opened` 列表中,否则拒绝 | -| db/captcha/smtp 仅本地监听 | gateway.ini | ✅ | 所有下游服务绑定 127.0.0.1,不对外暴露 | -| 配置无硬编码密钥 | gateway.ini | ✅ | 配置文件中无硬编码密码/API Key | -| 密码字段不记录日志 | 全文 | ✅ | 所有日志不记录明文密码、Token 等敏感字段 | - -### 新增安全机制(自2026-08-01以来) - -| 机制 | 位置 | 说明 | -|------|------|------| -| Token 吊销完整实现 | L538-754 | jti 黑名单 + 版本号双轨,支持单Token吊销和全设备吊销 | -| logout 吊销 Token | L1448-1455 | `/logout` 吊销当前 jti | -| refresh 轮换旧 Token | L1694-1697 | `/refresh_token` 吊销旧 jti,签发新 jti | -| reset_password 全设备吊销 | L2208 | 重置密码后调用 `revoke_all_user_tokens()` 递增版本号 | -| 线程安全的 CAPTCHA 存储 | L440-441, L480-494 | `_CAPTCHA_MTX` 互斥锁保护 | -| 线程安全的 Token 吊销存储 | L460, L554, L565 | `_TOKEN_REVOKE_MTX` 互斥锁保护 | -| 管理员 WS 代理鉴权 | L1140-1145 | WebSocket 升级前校验管理员权限 | -| 业务异常分级日志 | L1173-1178 | 5xx 记 ERROR,4xx 记 WARNING | - ---- - -## 问题修复路线图(更新版) - -### 立即修复(24小时内) -- [ ] **P1-7**: 为 POST 接口添加 CSRF Token 验证(或确认 API 使用场景仅为同源 + Bearer Token) -- [ ] **P1-3**: 为代理 HttpClient 显式设置连接超时和读取超时(建议连接5s,读取30s) - -### 短期修复(72小时内) -- [ ] **P1-2**: 添加账户级登录失败锁定(如同一邮箱5次失败后锁定15分钟) -- [ ] **P1-5**: 在 `req.body()` 读取前显式检查 `Content-Length`,超过 10MB 返回 413 -- [ ] **P1-8**: 日志客户端对用户输入字段(username/email)做换行符转义,防止日志注入 - -### 中期修复 -- [ ] **P2-3**: CORS 白名单生效,在 `handleProxy()` 响应中检查 Origin 并设置 `Access-Control-Allow-Origin` -- [ ] **P2-4**: 确认 HttpServer 超时参数语义,必要时补充 Read/Write/Idle 超时配置 -- [ ] **P2-6**: 为 username/email 添加显式长度限制(如邮箱最大254字符) -- [ ] **P2-2**: 如启用独立 HTTPS 模式(`tls = on`),增加 TLS 版本和密码套件配置项 - -### 已完成项 -- [x] **P0-1**: RSA 密钥未硬编码(设计接受,重启即密钥轮换) -- [x] **P0-2**: 错误页面脱敏(不再泄露源码路径/类型名) -- [x] **P0-3**: HttpException业务消息脱敏(替换内部步骤消息为用户友好文案,修正拼写和状态码) -- [x] P1-1: Token 吊销机制(jti + 版本号双轨) -- [x] P1-6: 反向代理响应大小限制(依赖 libhttp 10MB 限制) -- [x] P2-5: JTI 黑名单内存有界(5000条阈值 + 过期清理) - ---- - -## 代码验证记录 - -**验证日期**: 2026-08-07 -**验证方式**: 逐条对照初版审计报告与当前代码(逐行阅读 `main.cpp` 全部2418行 + 全部5个头文件) - -### 逐条验证结果 - -| 编号 | 初版状态 | 当前状态 | 代码位置 | 验证说明 | -|------|----------|----------|----------|----------| -| P0-1 | ⚠️ 部分 | ✅ 已修复 | L967, L451注释 | 重启重新生成RSA密钥是设计决策,配合版本号机制安全;注释明确说明设计意图 | -| P1-1 | ❌ 未修复 | ✅ 已修复 | L538-754 | jti黑名单+版本号双机制完整实现,logout/refresh/reset_password均调用吊销 | -| P1-2 | ⚠️ 部分 | ⚠️ 部分 | L1271 | IP限流5次/300s,仍无账户级锁定 | -| P1-3 | ❌ 未修复 | ⚠️ 部分 | L429-431, L2337-2342 | 下游服务超时已配置,代理HttpClient未显式设置 | -| P1-4 | ❌ 未修复 | ⏭️ 跳过 | libhttp | 属于libhttp层,libhttp修复率80% | -| P1-5 | ❌ 未修复 | ❌ 未修复 | L2341 | main.cpp未显式检查Content-Length | -| P1-6 | ❌ 未修复 | ✅ 已修复 | L2302注释 | libhttp HttpClient已修复默认10MB响应限制 | -| P2-2 | ❌ 未修复 | ❌ 未修复 | L963 | TLS版本/套件仍不可配置;当前tls=off,风险低 | -| P2-3 | ❌ 未修复 | ❌ 未修复 | L879, L2221-2354 | _CORS_ORIGINS已加载但handleProxy中未应用 | -| P2-4 | ❌ 未修复 | ⚠️ 部分 | L963, L965 | 传入4作为超时参数(连接超时),无读写超时 | -| P0-2(新增) | — | ✅ 已修复 | L1192-1213, ExpBase.hpp | std::exception返回通用消息,位置信息已注释 | -| P0-3(新增) | — | ✅ 已修复 | L1318, L1381, L1882, L2205 | 4处内部步骤消息已替换为用户友好文案,修正拼写和状态码 | -| P1-7(新增) | — | ❌ 未修复 | 全局POST接口 | SameSite=Lax但无CSRF Token | -| P1-8(新增) | — | ⚠️ 部分 | L1429, L1885, L2111 | 日志记录用户名/邮箱;日志为内部系统,风险低 | -| P2-5(新增) | — | ✅ 已修复 | L461, L569-578 | JTI黑名单5000条上限+过期清理 | -| P2-6(新增) | — | ⚠️ 部分 | L1283 | 用户名/邮箱无显式长度限制,db层兜底 | - ---- - -## 配置安全审计 - -**文件**: `confs/configs/gateway.ini` - -| 检查项 | 状态 | 说明 | -|--------|------|------| -| 监听地址 | ✅ 安全 | `address = 127.0.0.1`,仅本地监听 | -| TLS 模式 | ✅ 符合部署 | `tls = off`,TLS 由前置 WAF 终止 | -| 管理员白名单 | ✅ 安全 | `users` 显式配置管理员邮箱,无默认管理员 | -| CORS 白名单 | ⚠️ 配置未生效 | `allowed_origins` 已配置具体域名,但代码中未应用 | -| Token 有效期 | ✅ 合理 | `keep_time = 3600`(1小时),`max_refresh = 10` | -| WS 代理路由 | ✅ 安全 | 仅配置 `/ws/aibox` 一条路由,且需管理员权限 | -| 敏感凭据 | ✅ 无泄露 | 配置文件中无硬编码密码/密钥 | - ---- - -## 架构安全评估 - -### 安全分层(纵深防御) - -``` -互联网 - ↓ -waf:443 [TLS终止 + coraza规则匹配 + 速率限制] - ↓ -gateway:8443 [鉴权 + Token + 限流 + 代理白名单 + 路径校验] - ↓ - ├─ static:9200 [前端托管 + 路径遍历防护] - ├─ dnfs:8006 [树状笔记 + AI内容审核] - └─ (db/ai_review/captcha/smtp/confs 不对外暴露) -``` - -### 安全优势 - -1. **多层防护**: WAF边缘防护 + gateway应用层鉴权 + 服务最小暴露 -2. **密码安全**: PBKDF2慢哈希 + 唯一盐值 + 恒定时间比较 -3. **Token安全**: RSA签名 + 完整吊销机制 + 生命周期控制 -4. **防枚举**: 登录/重置均有时序拉平和统一响应 -5. **fail-closed**: 验证码/邮件服务不可用时拒绝请求,而非放行 -6. **内存安全**: C++20 智能指针 + RAII,无手动内存管理 -7. **异步安全**: 日志fail-safe设计,后端故障不影响主业务 -8. **重启即吊销**: 密钥内存生成,重启后所有旧Token失效 - -### 剩余风险 - -1. **CSRF**: POST接口缺少CSRF Token,依赖SameSite=Lax防护 -2. **CORS**: 白名单配置未生效,跨域策略依赖libhttp默认行为 -3. **账户锁定**: 仅IP级限流,可被代理池绕过进行定向账户爆破 -4. **超时配置**: 代理转发未显式设置超时,慢后端可能占用连接 -5. **请求体限制**: main.cpp层未显式限制请求体大小(依赖libhttp默认值) - ---- - -## 总结与建议 - -### 整体安全评级 - -**A-(优秀)** - **所有P0严重问题已全部修复**,核心安全机制完善,认证授权体系健全,剩余问题均为防御加固类,无直接可远程利用的严重漏洞。 - -### 优先修复建议 - -| 优先级 | 问题 | 预计工作量 | 安全收益 | -|--------|------|------------|----------| -| P1 | 代理超时显式配置 | 0.5小时 | 防慢后端连接耗尽 | -| P1 | CSRF Token 评估 | 1小时 | 防跨站请求伪造 | -| P1 | 请求体大小显式限制 | 0.5小时 | 防OOM攻击 | -| P1 | 账户级登录锁定 | 2小时 | 防爆破 | -| P2 | CORS 白名单生效 | 1小时 | 规范跨域策略 | - -### 上线前检查清单 - -- [ ] 确认 WAF 规则已启用(非注释状态) -- [ ] 确认所有服务绑定 127.0.0.1(aibox/redirect80除外) -- [ ] 确认云安全组/ufw仅放行TCP 443 -- [ ] 确认无默认管理员账户(首位注册者为管理员) -- [ ] 确认 `.env` 文件权限 0600,SMTP密码未硬编码 -- [ ] 确认 gateway 配置 `tls = off`、`address = 127.0.0.1` -- [ ] 回归测试:登录/注册/代理/Token刷新/吊销全链路正常 - ---- - -## 🟢 稳定性修复(2026-08-07) - -在安全审计过程中发现并修复了 gateway 进程"杀不死"问题,这是一个严重影响服务可靠性的bug: - -### 修复的问题 - -| # | 问题 | 严重程度 | 位置 | 修复方式 | -|---|------|----------|------|----------| -| 1 | 信号处理函数调用非 async-signal-safe 函数(std::cout、_SERVE->stop()、锁),可能死锁 | 高 | `main.cpp:763-767` | 改为仅设置 `volatile sig_atomic_t` 标志,主线程轮询后安全关闭 | -| 2 | 未处理 SIGPIPE,写关闭socket时进程可能被内核意外终止 | 中 | `main.cpp` | 新增 SIGPIPE 忽略处理 | -| 3 | Listener::doAccept() acceptor关闭后仍无条件递归,导致CPU 100%空转 | **严重** | `HttpServer_Listener.cpp:46` | 检查acceptor状态,关闭或operation_aborted时停止递归 | -| 4 | stop()未先关闭acceptor,停止过程中仍接受新连接 | 中 | `HttpServer.cpp:98-113` | 新增Listener::close()方法,stop()先关acceptor再停ioc | -| 5 | stop()/shutdown()无超时保护,网络IO阻塞时join线程永久挂起 | 高 | `main.cpp` | stop() 8秒超时,日志flush 3秒超时,超时强制exit保证进程退出 | -| 6 | 主线程直接运行ioc->run(),无法安全响应信号 | 中 | `main.cpp` | 服务器在独立线程运行,主线程负责信号检测和关闭协调 | - -### 修复后的关闭保证 - -- **进程绝不挂死**:双重超时保护,极端情况下最多11秒内必定退出 -- **信号处理安全**:杜绝信号处理函数中的死锁风险 -- **资源正确清理**:acceptor→WS客户端→ioc→worker线程 有序关闭 -- **SIGPIPE防护**:写关闭连接不再导致进程意外终止 - -### 修改文件 - -- `gateway/src/main.cpp` - 信号处理安全重构、超时保护、独立服务器线程 -- `libhttp/src/HttpServer.cpp` - 改进stop()流程顺序 -- `libhttp/src/HttpServer_Listener.cpp` - 修复无限递归bug,新增close() -- `libhttp/inc/us/detail/HttpServerImpl.hpp` - 添加Listener::close()声明 - ---- - -**审计人**: AI Security Auditor -**复审日期**: 2026-08-07 -**下一次复审建议**: 修复P1问题后或代码有重大变更时 diff --git a/docs/security/gateway_SECURITY_AUDIT_20260809.md b/docs/security/gateway_SECURITY_AUDIT_20260809.md deleted file mode 100644 index 50770f7..0000000 --- a/docs/security/gateway_SECURITY_AUDIT_20260809.md +++ /dev/null @@ -1,265 +0,0 @@ -# gateway 模块安全审计报告(2026-08-09 增量复审) - -**审计日期**: 2026-08-09 -**审计范围**: `src/main.cpp`(v0.8.0,2526 行全量)、`inc/DbC.hpp`、`inc/LogC.hpp`、`inc/Conf.hpp`、`inc/ExpBase.hpp` -**基线文档**: [gateway_SECURITY_AUDIT_20260807.md](gateway_SECURITY_AUDIT_20260807.md)(P0 3/3 已修复、P1 8 项 56%、P2 6 项 33%) -**审计方式**: 全量代码走查 + 实机黑盒实证(重建二进制 → 启动 8443 → curl 探针 12 组 → 对照基线做增量发现) -**配套交付**: `tests/blackbox/test_gateway.py` 重写 + `helpers.py` 增强(见 §六) - ---- - -## 代码修复记录 - -**修复日期**: 2026-08-09(审计当日) - -| 编号 | 状态 | 代码位置 | 修复说明 | -|------|------|----------|----------| -| A1 | ✅ 已修复 | `src/main.cpp` 关闭流程 | 删除 else 分支重复的 `log_flush_thread.join()`(ready 时 async 已完成 join) | -| A2 | ✅ 已修复 | `handleProxy` 白名单检查 | `throw std::runtime_error("403")` → `THROW_EX_CUSTOM(HttpException, 2303, "Forbidden: target not allowed", 403, "*")` | -| A3 | ✅ 已修复 | `handleLogin`/`handleRegister`/`handleResetPassword` | 字段提取 `obj.at()` 包 try/catch,缺字段返回 400 | -| A5 | ✅ 已修复 | `is_special_header()` | 比较前统一转小写,小写 `x-forwarded-for` 等变体不再绕过剥离 | -| A6 | ✅ 已修复 | `handleRegister` CAPTCHA 校验 | `verify_captcha()`(内置存储)→ `verify_captcha_remote()`(captcha2),与 `/captcha/image` 渠道互通 | -| A7 | ✅ 已修复 | `src/main.cpp` 内置验证码渠道 | 整体移除:`POST /captcha` 路由、`handleCaptcha`、`generate_captcha_text`/`verify_captcha`、`_CAPTCHA_STORE`/`_CAPTCHA_MTX`(净 -79 行);未注册路径按既有通配代理语义处理 | -| A8 | ✅ 已修复 | `wrapperHttp` HttpException 分支 | 客户端消息从 `e.what()` 改为 `e.message()`,`[Http][Error NNNN]` 前缀仅保留在服务端 stderr/集中日志 | -| A4 | 📝 文档化 | `README.md` 部署模式 B | 根因需 libhttp 向 handler 透传 TCP 对端地址(跨模块 API 变更),本轮以文档化闭环:README 模式 B 增加安全降级提示(伪造 XFF 可绕过限流,生产必须用模式 A) | - -### 修复验证 - -| 验证项 | 结果 | -|--------|------| -| 编译(make,g++ -std=c++20) | ✓ 通过(仅 LogC.hpp 既有 snprintf 警告) | -| 黑盒全量(test_gateway.py 34 断言) | ✓ **32 passed / 0 failed / 2 skipped**,锁定 A3 的两个红测试转绿 | -| A1 关闭路径 | ✓ `kill` 后 "已退出" 正常打印,进程 Done 退出,无 core dump(两轮构建均复核) | -| A2 | ✓ 代码路径确认(HttpException 经 wrapperHttp 映射 403;黑盒触达需有效 Token,待测试钩子) | -| A8 实证 | ✓ `POST /login {}` → `{"success":false,"message":"Bad_Request"}`,无 `[Http][Error 2002]` 前缀 | -| A7 实证 | ✓ `POST /captcha` 不再返回答案(落入通配代理语义,与其他未注册路径行为一致) | -| 黑盒全量回归(修复 A6/A7/A8 后) | ✓ 32 passed / 0 failed / 2 skipped,无回归 | - ---- - -## 一、审计结论摘要 - -本次为增量复审,基线已确认项不重复列出(复测见 §五)。新发现 8 项: - -| 等级 | 数量 | 编号 | -|------|------|------| -| 🔴 P0 | 0 | — | -| 🟠 P1 | 2 | A1(优雅关闭必崩溃)、A2(白名单拒绝返回 500 而非 403) | -| 🟡 P2 | 4 | A3(缺字段 500)、A4(XFF 伪造绕过限流)、A5(头名大小写架空 P1-1 修复)、A6(双 CAPTCHA 渠道不互通) | -| ⚪ P3 | 2 | A7(内置验证码接口明文给答案)、A8(内部错误码前缀外泄) | - -**未发现新的 P0**。Token 验签链(RSA + 过期 + jti 黑名单 + 版本号)、PBKDF2(10 万轮 + 常数时间比较)、fail-closed 下游语义、防用户枚举 dummy 校验等核心机制实现正确。 - ---- - -## 二、P1 详细发现 - -### A1 [P1] 优雅关闭以崩溃收场:`log_flush_thread` double-join - -**位置**: `src/main.cpp` L1066-1077 - -```cpp -std::thread log_flush_thread([]() { logs().shutdown(); }); -auto future = std::async(std::launch::async, &std::thread::join, &log_flush_thread); -if (future.wait_for(std::chrono::seconds(3)) == std::future_status::timeout) { - log_flush_thread.detach(); -} else { - log_flush_thread.join(); // ← async 已完成 join,此处二次 join -} -``` - -**根因**: `std::async(std::launch::async, &std::thread::join, &t)` 已在独立线程中执行完 `join()`;`wait_for` 返回 ready 后 else 分支再次 `t.join()`,此时 `joinable()==false` → 抛 `std::system_error`(EINVAL)→ 无人捕获 → `std::terminate` → SIGABRT。 - -**实证**: -``` -$ kill -terminate called after throwing an instance of 'std::system_error' - what(): Invalid argument -[1]+ Aborted (core dumped) nohup ./build/gateway.out -``` -"已退出" 收尾日志不打印,进程以 core dump 结束。基线报告修复的"进程退出 bug"(0807 版)实际只修了一半:main 函数体能走完,但关闭路径最后一步仍崩溃。 - -**影响**: 每次正常关闭(SIGINT/SIGTERM)都产生 core dump;systemd 下触发不必要告警;关闭期日志是否落库无从保证。 - -**修复建议**: 删除 else 分支中重复的 `log_flush_thread.join()`(ready 即已 join 完毕)。 - -### A2 [P1] 白名单外 Target-Url 返回 500 而非文档宣称 403 - -**位置**: `src/main.cpp` L2401-2404(`handleProxy`) - -```cpp -if (!is_safe_target(target)) { - throw std::runtime_error("403"); // 意图返回 403 -} -``` - -**根因**: `std::runtime_error` 不是 `HttpException`,被 `wrapperHttp()`(L1288-1303)的 `std::exception` 兜底分支统一包装为 500 "Internal Server Error"。README 接口契约明确写"目标不在白名单 → 403"。 - -**实证局限**: 白名单检查在 Token 验签之后,无有效 Token 时只能走到 401,黑盒无法触达;但代码路径确凿(`runtime_error` 必经 500 兜底)。待测试钩子落地后补黑盒断言。 - -**影响**: 客户端无法区分"目标被拒"与"服务器故障";监控告警误报 500。 - -**修复建议**: 改为 `THROW_EX_CUSTOM(HttpException, 2303, "Forbidden", 403, "*")`,与 L2370 验签失败的风格一致。 - ---- - -## 三、P2 详细发现 - -### A3 [P2] 缺失 JSON 字段 → 500 而非 400 - -**位置**: `src/main.cpp` L1373-1374(`handleLogin`,同型问题遍布 `handleRegister`/`handleResetPassword`/`handleSendEmailCode` 等) - -```cpp -auto& obj = json.as_object(); -std::string username = obj.at("username").as_string().c_str(); // ← 在 try 块外 -std::string password = obj.at("password").as_string().c_str(); -``` - -**根因**: JSON 语法解析有 try/catch(解析失败 → 400),但随后的字段提取 `obj.at()` 在 try 之外,`std::out_of_range` 直达 `wrapperHttp` 兜底 → 500。 - -**实证**: `POST /login` body `{}` → **500**(应为 400 Bad Request)。已由新版黑盒测试 A5/A8 两个用例锁定(当前红,修复后转绿)。 - -**修复建议**: 各 handler 的字段提取统一包进 try/catch,缺字段返回 400;或提供 `get_or_throw_400(obj, key)` 辅助函数。 - -### A4 [P2] 独立部署模式 B 下限流可被 XFF 伪造绕过 - -**位置**: `src/main.cpp` L1353-1359(`handleLogin`,`handleRegister`/`handleCaptchaImage` 同型) - -```cpp -std::string client_ip = req.base()["X-Forwarded-For"]; // 直连模式下即客户端伪造值 -if (client_ip.empty()) client_ip = req.remote_ip(); -``` - -**根因**: 模式 A(WAF 前置)中 WAF 会覆写 XFF,此逻辑安全;模式 B(独立部署)中 XFF 完全由客户端控制,每个伪造 IP 都是独立限流桶 → 限流失效。基线报告 P1-1 修复的是"向下游注入可信 XFF",限流侧未覆盖。 - -**实证**: -``` -XFF=10.1.1.1 连续 6 次 POST /login → 第 6 次 429 -XFF=10.1.1.2 立即再发 → 400(未限流,新桶) -``` - -**影响**: 登录爆破/注册滥用防护在独立部署下可绕过(每次请求换一个 XFF 即无上限)。 - -**修复建议**: 二选一——(a) 配置项声明部署模式,模式 B 下限流 key 只用 `remote_ip()`;(b) README 明确"独立部署时安全降级:限流不可用"并建议仅在 WAF 后使用。 - -### A5 [P2] `is_special_header` 大小写敏感,伪造 XFF 绕过剥离,架空 P1-1 修复 - -**位置**: `src/main.cpp` L2464-2476 - -```cpp -static bool is_special_header(const std::string& name) { - return name == "Host" || name == "connection" || name == "Accept-Encoding" || - name == "X-Forwarded-For" || name == "X-Real-IP" || name == "Forwarded"; -} -``` - -**根因**: Boost.Beast 的 `field.name_string()` 保留线上原始大小写。客户端发送小写 `x-forwarded-for: 6.6.6.6` 时不匹配 `"X-Forwarded-For"`,不被剥离;gateway 随后注入可信 XFF,下游收到**两个** XFF 头——若下游取第一个(常见实现),攻击者伪造值生效,基线 P1-1"注入可信 XFF"的修复被架空。 - -**实证**: 代码级确认(`name_string()` 行为见 Beast 文档;代理剥离逻辑 L2410-2420 逐头比对)。 - -**修复建议**: 比较前统一转小写(`boost::beast::iequals` 或手动 lowercase 后查集合),并将 `connection`/`keep-alive`/`transfer-encoding` 等逐跳头一并规范化。 - -### A6 [P2] 注册/登录 CAPTCHA 双渠道不互通,README 与实现不符 - -**位置**: `src/main.cpp` L1954(`handleRegister`)vs L1845-1887(`handleCaptchaImage`) - -**根因**: 注册接口用 `verify_captcha()` 校验**内置** `_CAPTCHA_STORE`(内置 `GET/POST /captcha` 接口签发的 32 位 token);而前端实际通过 `/captcha/image` 代理拿的是 **captcha2** 签发的 64 位 token。两个存储互不通信——前端走完图形验证码流程后,注册提交 captcha_token 永远 invalid。`/login` 走的是远端 `verify_captcha_remote()`(captcha2),与 `/captcha/image` 配套,正常。 - -**实证**: 不带 captcha 字段 `POST /login` → 400 Bad_Request(README 称"可选",代码实为强制)。 - -**影响**: 注册流程的 CAPTCHA 环节对真实前端不可用(目前靠"不提交则跳过"绕过——即注册 CAPTCHA 形同虚设);文档误导。 - -**修复建议**: 统一走 `verify_captcha_remote()`;废弃内置验证码接口(见 A7);README 更新为"登录强制 CAPTCHA"。 - ---- - -## 四、P3 详细发现 - -### A7 [P3] 内置 `POST /captcha` 明文返回答案,人机验证无效化 - -**位置**: `src/main.cpp` 内置验证码接口(`_CAPTCHA_STORE` 配套路由) - -**实证**: -``` -$ curl -X POST :8443/captcha -{"token":"...","text":"AmM2U"} ← 答案明文随 token 一起返回 -``` - -**叠加问题**: 该接口无速率限制、`_CAPTCHA_STORE` 无容量上限(内存 DoS 面)。 - -**修复建议**: 直接废弃内置验证码接口与 `_CAPTCHA_STORE`(captcha2 已全面接管),删除路由。 - -### A8 [P3] 错误消息携带内部错误码前缀外泄 - -**实证**: 错误响应体形如 `[Http][Error 2002] Bad_Request` —— 内部模块名与错误码编号随 4xx 返回客户端。 - -**评估**: 不构成路径/类型泄露(P0-2/P0-3 的脱敏基线未违反,16 条错误样本回归测试通过),但为攻击者提供内部结构测绘素材,且污染前端可直接展示的消息。 - -**修复建议**: `wrapperHttp` 返回客户端前剥离 `[模块][Error NNNN]` 前缀(完整信息保留在服务端日志)。 - ---- - -## 五、基线遗留项复测 - -| 编号 | 项 | 复测结果 | -|------|----|----------| -| P1-1 | 伪造 XFF 注入下游 | 修复已生效(标准大小写头被剥离),但**被 A5 架空** | -| P2-3 | CORS 反射任意 Origin | ❌ 仍存在:`Origin: https://evil.example.com` 被原样反射进 ACAO | -| — | 路径遍历防护 | ✅ `..`、`%2e`、`%2f`、`%5c` 四种变体全部 400 | -| — | `/whoami` 无/伪造 Token | ✅ 恒 `{login:false}`,不泄露验签细节 | -| — | `/admin/verify` fail-closed | ✅ 无/伪造 Token 均 401 | -| — | `/captcha/image` 代理 | ✅ 200 + image/png + 64 位 hex token + `no-store` + 限流 10/min/IP | -| — | 默认代理下游宕机语义 | ⚠️ static 宕机时默认路由 500(环境语义,非缺陷) | - ---- - -## 六、黑盒测试补充(已落地) - -### 6.1 测试套件脱节结论 - -- **test_gateway.py 旧版 10 场景已失效**:v0.8.0 后 /login 强制远端 CAPTCHA、/register 强制 email_code,无 OCR/收信能力的黑盒 happy path 全断;且无 db 清理,反复运行污染 Users 表 -- **test_captcha.py / test_smtp.py 形同虚设**:用 HTTP 测 pymsg/TCP 服务(captcha2:8009、smtp:8008),captcha 用例期望 32 位 token(captcha2 实为 64 位) -- **可测试性根因**:认证 happy path 需要测试钩子——方案 a:captcha2/smtp 提供测试模式(固定验证码答案);方案 b:`gateway.ini` 增加 `[Security] captcha_required/email_code_required` 开关 - -### 6.2 本轮交付(P0 部分,无需钩子) - -**`tests/blackbox/helpers.py` 增强(+48 行)**: - -| 工具 | 用途 | -|------|------| -| `random_xff()` | 随机 XFF 隔离限流桶(利用 A4 行为,避免限流用例互斥) | -| `assert_no_internal_leak()` | 错误响应脱敏回归(P0-2/P0-3 基线:无源码路径/C++ 类型名) | -| `db_execute()` | pymsg 调 db 做测试数据准备/清理 | - -**`tests/blackbox/test_gateway.py` 重写(34 断言 6 段)**: - -| 段 | 内容 | 断言数 | -|----|------|--------| -| A | 输入校验新契约(强制 captcha/email_code、fail-closed 语义) | 8 | -| B | `/whoami`、`/admin/verify`(含伪造 Token) | 4 | -| C | `/captcha/image` 代理(200/PNG/64 位 token/no-store) | 4 | -| D | 无 Token 401 + 路径遍历矩阵(原始请求行,`%2e/%2f/%5c` 变体) | 5 | -| E | 限流全谱:登录 6th→429、注册 4th→429、发码 6th→429、验证码 11th→429、换 XFF 隔离 | 9 | -| F | CORS 反射现状固化(P2-3 修复后翻转断言)+ 16 条错误样本脱敏回归 | 2 | -| 尾 | 测试用户清理(db `like` 删除,旧版 `test_%` 污染仅报告) | 2 | - -**实测结果**(gateway 实跑): `34 tests: 30 passed, 2 failed, 2 skipped` - -- 2 个 FAIL 为刻意锁定 A3 的红测试(缺字段断言 400,当前实现 500),修复后自然转绿 -- 2 个 SKIP 为环境性(static 未运行;本地 db 无 Users 表——**本环境下注册写库必 500**,跑 P1 认证链路测试前需先执行 `db/create_sqlite3.sql` 建表) - -### 6.3 后续清单(需钩子,P1/P2) - -- 完整旅程(captcha → send_code → register → login → whoami → logout)、Cookie 链路(Set-Cookie 属性、代理注入 Authorization)、jti/版本号吊销、refresh_token、白名单 403(A2 转绿)、防枚举时序 -- test_captcha.py / test_smtp.py 重写为 pymsg 直连 - ---- - -## 七、修复建议优先级 - -| 优先级 | 项 | 说明 | -|--------|----|------| -| P1 | A1、A2 | double-join 删 3 行;A2 改抛 HttpException,均为小改动 | -| P2 | A3、A5、A6、A4 | A3 修复后两个红测试转绿;A5 头名归一化;A6 统一走 captcha2;A4 配置或文档化 | -| P3 | A7、A8 | 废弃内置验证码接口;错误消息剥离内部前缀 | -| 配套 | 测试钩子 | captcha2/smtp 测试模式或 gateway.ini 开关,解锁 P1 测试清单 | diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index ffb4b31..789a3b9 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -24,7 +24,9 @@ namespace po = boost::program_options; /* v0.9.0:认证独立化瘦身(waf → ca → gateway 前置拓扑) * - 认证资产(Token 签发/验签、登录/注册/重置、WS 代理鉴权)已全部迁至 ca 模块 * - gateway 退化为纯转发服务:仅 loopback 监听(默认 8445),不验签 - * - 身份契约:显式 Target-Url 请求必须携带 ca 注入的 X-Auth-User 头(存在性不变量) */ + * - 身份契约:显式 Target-Url 请求必须携带 ca 注入的 X-Auth-User 头(存在性不变量) + * - 安全审计结论见 docs/security/gateway_SECURITY_AUDIT.md(合并文档:开放项/历史追溯/上线清单); + * 已修复项以“审计 A*”/“P1-1”等标记内联于下方对应代码 */ constexpr const char *_VER = "0.9.0"; ////////////////////////////////// From 373045f0a3378ef186f81ddb6cb7f9c3f7ffa0a8 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 01:08:55 +0800 Subject: [PATCH 008/196] =?UTF-8?q?pymsg:=20=E5=B0=86=E5=B7=B2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AE=89=E5=85=A8=E5=AE=A1=E8=AE=A1=E9=A1=B9=E5=90=88?= =?UTF-8?q?=E5=85=A5=E6=BA=90=E7=A0=81=E6=B3=A8=E9=87=8A,=20=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E5=AE=A1=E8=AE=A1=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 P0-1/P0-2/P1-1/P1-2/P1-3/P1-5 共 6 条已修复发现作为 [SECURITY AUDIT ... ✅ 已修复] 注释合入 pymsg 源码对应位置 - 重写 pymsg 审计报告, 删除已合并条目, 仅保留 10 条未修复项待跟踪 --- .../security/pymsg_SECURITY_AUDIT_20260801.md | 138 +++++++----------- pymsg/pymsg/__init__.py | 38 ++++- pymsg/pymsg/rpc.py | 15 +- 3 files changed, 99 insertions(+), 92 deletions(-) diff --git a/docs/security/pymsg_SECURITY_AUDIT_20260801.md b/docs/security/pymsg_SECURITY_AUDIT_20260801.md index d92161f..0fcf21e 100644 --- a/docs/security/pymsg_SECURITY_AUDIT_20260801.md +++ b/docs/security/pymsg_SECURITY_AUDIT_20260801.md @@ -6,33 +6,44 @@ --- -## 代码验证记录 +## ⚠️ 合并追溯说明(2026-08-10) -**验证日期**: 2026-08-03 -**验证方式**: 逐条对照审计报告与当前代码(`__init__.py`、`rpc.py`、`async_queue.py`) +原始审计报告共 16 条发现(P0×3 / P1×5 / P2×4 / P3×4)。其中 **6 条已修复项** +(P0-1、P0-2、P1-1、P1-2、P1-3、P1-5)的修复代码已落地并经 2026-08-03 验证, +现已作为 `[SECURITY AUDIT ... ✅ 已修复]` 注释合并进源文件对应位置: -### 验证结果汇总 +| 编号 | 已合并位置 | +|------|------------| +| P0-1 | `pymsg/__init__.py` — `recv()`、`_recv_exact()` | +| P0-2 | `pymsg/__init__.py` — `recv()`、`_handle_client()` | +| P1-1 | `pymsg/__init__.py` — `MsgServer.__init__`、`_accept_client()` | +| P1-2 | `pymsg/__init__.py` — `_BoundedThreadPoolExecutor` 类、`MsgServer.__init__`、`_handle_client()` | +| P1-3 | `pymsg/rpc.py` — `_sanitize_error()` | +| P1-5 | `pymsg/rpc.py` — 模块顶部 `_MAX_NUM_DIGITS` 注释、`_dispatch()` | -| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | -|------|------|--------|----------|--------|--------| -| P0 | 3 | 2 | 0 | 1 | 67% | -| P1 | 5 | 4 | 0 | 1 | 80% | -| P2 | 4 | 0 | 0 | 4 | 0% | -| P3 | 4 | 0 | 0 | 4 | 0% | -| **合计** | **16** | **6** | **0** | **10** | **37.5%** | +**本报告以下章节仅保留尚未修复(未合并)的 10 条发现作为待办跟踪**,已修复条目 +已从「逐条验证」「修复进度」及详细问题章节中删除,其完整描述见源代码注释。 -### 逐条验证 +--- -| 编号 | 状态 | 代码位置 | 验证说明 | -|------|------|----------|----------| -| P0-1 | ✅ 已修复 | `__init__.py` L103-120, L140-170 | `recv()` 改为 deadline 模式:`_recv_exact` 接收 `time.monotonic()+timeout` 作为总截止时刻,每次 `recv_into` 前按剩余时间收紧 socket 超时;header 与 payload 共享同一总超时,逐字节发送无法刷新 deadline,半开连接被及时关闭(已实测:0.6s 超时 0.60s 即抛 TimeoutError) | -| P0-2 | ✅ 已修复 | `__init__.py` L113-114, L462-463 | 双重防护:`recv()` 将 `UnicodeDecodeError` 包装为 `ConnectionError`;`_handle_client` 防御性捕获 `UnicodeDecodeError` | +## 剩余未修复条目(待跟踪) + +### 汇总 + +| 等级 | 剩余总数 | 说明 | +|------|----------|------| +| P0 | 1 | 无认证/鉴权机制 | +| P1 | 1 | 明文 TCP,无 TLS | +| P2 | 4 | 健壮性/配置问题 | +| P3 | 4 | 优化建议 | +| **合计** | **10** | | + +### 逐条验证(仅未合并项) + +| 编号 | 状态 | 代码位置 | 说明 | +|------|------|----------|------| | P0-3 | ❌ 未修复 | `rpc.py` L80-171 | 无认证机制,仅依赖 127.0.0.1 网络层隔离 | -| P1-1 | ✅ 已修复 | `__init__.py` L342-350, L467-484 | `MsgServer` 新增 `max_connections` 参数(默认 1000),`_accept_client` 在 `len(_clients) >= max_connections` 时直接拒绝并关闭新连接;已实测 max_connections=3 时第 4 个连接被关闭(ConnectionResetError),handler 只处理前 3 条 | -| P1-2 | ✅ 已修复 | `__init__.py` L316-333, L369, L405-410, L530-540 | 新增 `_BoundedThreadPoolExecutor`(`submit` 时检查 `_work_queue.qsize()`,超限抛 `queue.Full`);`MsgServer` 新增 `max_queue_size` 参数(默认 1000),`_handle_client` 捕获 `queue.Full` 后拒绝并断开该连接;已实测 workers=1 + max_queue_size=1 时,慢 handler 阻塞下第 3 个连接被拒绝关闭,handler 只处理前 2 个任务 | -| P1-3 | ✅ 已修复 | `rpc.py` L49-57, L160-178 | 新增 `_sanitize_error()`:用 `_PATH_RE` 正则将异常信息中的绝对路径(如 `/opt/micro_servers/db/xxx.py`)替换为 ``,保留业务错误语义;`_dispatch` 返回给客户端前统一调用;已实测异常信息含绝对路径时返回体仅含 ``,不透传 `/opt/...` | | P1-4 | ❌ 未修复 | 全模块 | 明文 TCP,无 TLS 支持 | -| P1-5 | ✅ 已修复 | `rpc.py` L28-46, L158-166 | `json.loads` 增加 `parse_int=_checked_int` / `parse_float=_checked_float` 钩子,数字位数超 `_MAX_NUM_DIGITS=100` 抛 `ValueError`;`except` 捕获 `RecursionError` 防止深度嵌套导致栈溢出;解析失败统一返回 `"invalid JSON request"` 不透传输入片段;已实测超大整数(10000 位)与深度 200000 嵌套均被拒绝,服务存活 | | P2-1 | ❌ 未修复 | `__init__.py` L106-111 | length=0 空消息无特殊处理 | | P2-2 | ❌ 未修复 | `__init__.py` L395-418 | `broadcast()` 无频率/权限控制 | | P2-3 | ❌ 未修复 | `__init__.py` L377-378 | `except (ValueError, OSError): break` 仍捕获所有 OSError(含 EINTR) | @@ -55,44 +66,20 @@ --- -## 修复进度 +## 修复进度(剩余) -| 优先级 | 总数 | 已修复 | 待修复 | -|--------|------|--------|--------| -| P0 | 3 | 2 | 1 | -| P1 | 5 | 2 | 3 | -| P2 | 4 | 0 | 4 | -| P3 | 4 | 0 | 4 | +| 优先级 | 剩余待修复 | +|--------|------------| +| P0 | 1(无认证) | +| P1 | 1(无 TLS) | +| P2 | 4 | +| P3 | 4 | --- ## 🔴 P0 — 严重问题 -### P0-1: recv 超时可被绕过(Slowloris 变种 DoS)✅ 已修复 -- **位置**: `__init__.py:103-120`(`recv`)、`__init__.py:140-170`(`_recv_exact`) -- **原问题**: `sock.settimeout()` 设置的是**单次系统调用**超时,不是整个消息接收总超时。攻击者可每次在快超时时发送 1 字节: - - 4 字节长度头最长可占用 `4 * recv_timeout = 40秒` - - 载荷部分按字节发送可使连接存活任意长时间 -- **影响**: 少量攻击者即可耗尽连接/线程资源,造成所有依赖 pymsg 的服务拒绝服务 -- **修复方案(deadline 模式)**: - - `recv()` 在开头计算 `deadline = time.monotonic() + timeout`,header 与 payload 共享**同一总截止时刻** - - `_recv_exact` 每次 `recv_into` 前按 `deadline - now` 设置剩余 socket 超时,剩余时间耗尽即抛 `TimeoutError` - - 逐字节发送会不断刷新单次 `sock.settimeout()`,但**无法刷新总 deadline**,半开连接会被及时关闭 - - 实测验证:超时 0.6s 时,逐字节攻击在 0.60s 即被 TimeoutError 终止(旧实现需等到完整消息 1.4s) - ---- - -### P0-2: UnicodeDecodeError 可导致同步模式服务崩溃 ✅ 已修复 -- **位置**: `__init__.py:113-114`、`__init__.py:462-463` -- **原问题**: `payload.decode("utf-8")` 抛出的 `UnicodeDecodeError`(`ValueError` 子类)不在 `_handle_client` 的异常捕获列表中,同步模式下收到无效 UTF-8 字节会直接导致整个服务进程崩溃 -- **修复方案(双重防护)**: - 1. `recv()` 函数将 `UnicodeDecodeError` 包装为 `ConnectionError`(第一层) - 2. `_handle_client` 异常捕获列表添加 `UnicodeDecodeError`(第二层,防御性兜底) -- **影响范围**: 已消除,db/confs/smtp/captcha 等所有 pymsg 服务均受保护 - ---- - -### P0-3: 无认证/鉴权机制(已部分缓解:绑定 127.0.0.1) +### P0-3: 无认证/鉴权机制 - **位置**: `rpc.py:80-171` 整个 RpcServer - **问题**: RPC 服务完全没有身份验证,任何能连接到端口的客户端都能调用所有路由 - **当前缓解**: 所有服务均绑定 127.0.0.1,依赖网络层隔离 @@ -107,11 +94,7 @@ | # | 问题 | 位置 | 状态 | |---|------|------|------| -| P1-1 | 无连接数限制,连接耗尽攻击 | `__init__.py:342-350,467-484` | ✅ 已修复(`max_connections` 默认 1000) | -| P1-2 | 线程池无队列限制,慢 handler 耗尽内存 | `__init__.py:316-333,369,405-410,530-540` | ✅ 已修复(`_BoundedThreadPoolExecutor` + `max_queue_size` 默认 1000) | -| P1-3 | 异常信息直接返回客户端,泄露内部细节 | `rpc.py:49-57,160-178` 新增 `_sanitize_error()` 脱敏绝对路径 | ✅ 已修复 | | P1-4 | 无 TLS/加密传输,内网流量可被窃听 | 全模块明文 TCP | ❌ 未修复 | -| P1-5 | JSON 解析无深度限制,深度嵌套导致栈溢出/CPU 100% | `rpc.py:28-46,158-166` `parse_int/parse_float` 钩子 + 捕获 `RecursionError` | ✅ 已修复 | --- @@ -141,39 +124,22 @@ 1. ✅ 64 MiB 消息大小上限(`_MAX_MSG_SIZE`),防止 OOM 2. ✅ send/recv 双端都检查消息大小 -3. ✅ recv_timeout 防 Slowloris(基础实现,需修复 P0-1) -4. ✅ 线程池模式下同一连接串行处理,避免帧交错 -5. ✅ 使用 `sendmsg` 散射-聚集 I/O 减少内存拷贝 -6. ✅ 使用 `recv_into` 预分配 buffer -7. ✅ 线程安全的发送锁 -8. ✅ 回调异常隔离(线程池模式) -9. ✅ 单元测试覆盖消息大小边界 +3. ✅ 线程池模式下同一连接串行处理,避免帧交错 +4. ✅ 使用 `sendmsg` 散射-聚集 I/O 减少内存拷贝 +5. ✅ 使用 `recv_into` 预分配 buffer +6. ✅ 线程安全的发送锁 +7. ✅ 回调异常隔离(线程池模式) +8. ✅ 单元测试覆盖消息大小边界 -## 新增安全机制(审计后添加,审计报告未覆盖) - -| 机制 | 代码位置 | 说明 | -|------|----------|------| -| UnicodeDecodeError 双重防护 | `__init__.py` L113-114, L462-463 | `recv()` 包装为 `ConnectionError` + `_handle_client` 防御性捕获,防止无效 UTF-8 导致服务崩溃 | -| AsyncRpcQueue 有界队列 | `async_queue.py` L47, L130-132 | `max_queue_size` 默认 10000,超限丢弃最旧数据,防止内存无限增长 | -| AsyncRpcQueue 错误隔离 | `async_queue.py` L49, L189-194 | `on_error` 回调 + 异常捕获,后台线程失败不影响主业务 | -| RpcClient 响应格式校验 | `rpc.py` L89-90 | 校验响应必须是含 `success` 字段的 dict,非法响应抛出 `RpcError` | -| MsgClient.call 节制日志 | `__init__.py` L209, L248-254 | 重试日志首次立即打印,后续每 5 秒一次,避免刷屏 | +> 注:P0-1(recv 总超时)、P0-2(UnicodeDecodeError 防护)、P1-1(连接数限制)、 +> P1-2(有界队列)、P1-3(异常脱敏)、P1-5(JSON 解析限制)等已修复机制均已合并进 +> 源代码注释,见开头「合并追溯说明」。 --- -## 修复路线图 - -### 立即(24小时) -- [x] 修复 P0-1: recv 总超时(deadline 模式)✅ 已完成 -- [x] 修复 P0-2: 捕获 UnicodeDecodeError/JSONDecodeError ✅ 已完成 - -### 短期(72小时) -- [x] 添加最大连接数限制 ✅ 已完成 -- [x] 线程池改用有界队列 ✅ 已完成 -- [x] 异常信息脱敏 ✅ 已完成 -- [x] JSON 解析深度限制 ✅ 已完成 +## 修复路线图(剩余) ### 中期 -- [ ] 统一认证机制 -- [ ] TLS 支持选项 -- [ ] 安全事件日志 +- [ ] 统一认证机制(P0-3) +- [ ] TLS 支持选项(P1-4) +- [ ] 安全事件日志(P3-1) diff --git a/pymsg/pymsg/__init__.py b/pymsg/pymsg/__init__.py index 849d239..5438a4f 100644 --- a/pymsg/pymsg/__init__.py +++ b/pymsg/pymsg/__init__.py @@ -101,6 +101,12 @@ def recv( message is received. TimeoutError: If the operation exceeds the timeout. """ + # [SECURITY AUDIT P0-1 ✅ 已修复] recv 总超时(deadline 模式) + # 原问题:sock.settimeout() 是「单次系统调用」超时而非整条消息总超时, + # 攻击者可每次在快超时时发 1 字节绕过,造成 Slowloris 型连接耗尽 DoS。 + # 修复:header 与 payload 共享同一总截止时刻 deadline,每次 recv_into + # 前按剩余时间收紧 socket 超时,半开连接被及时关闭(实测 0.6s 超时即终止)。 + # 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md deadline = None if timeout is not None: deadline = time.monotonic() + timeout @@ -113,6 +119,11 @@ def recv( ) payload = _recv_exact(sock, length, deadline) return payload.decode("utf-8") + # [SECURITY AUDIT P0-2 ✅ 已修复] UnicodeDecodeError 导致同步服务崩溃 + # 原问题:payload.decode("utf-8") 抛出的 UnicodeDecodeError(ValueError 子类) + # 不在 _handle_client 捕获列表中,同步模式收到无效 UTF-8 会令整个进程崩溃。 + # 修复(双重防护):此处将 UnicodeDecodeError 包装为 ConnectionError(第一层), + # _handle_client 再防御性捕获兜底(第二层)。db/confs/smtp/captcha 等服务均受保护。 except UnicodeDecodeError: raise ConnectionError("invalid UTF-8 payload") from None except (TimeoutError, _socket.timeout): @@ -155,6 +166,9 @@ def _recv_exact( ``TimeoutError``。这防止 Slowloris 型攻击:逐字节发送会不断刷新单次 ``sock.settimeout()``,但无法刷新总 deadline,半开连接会被及时关闭。 """ + # [SECURITY AUDIT P0-1 ✅ 已修复] 本函数为 P0-1 的核心修复实现, + # 与上方 recv() 的 deadline 计算配合,构成整条消息的总超时防护。 + # 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md buf = bytearray(n) view = memoryview(buf) got = 0 @@ -313,6 +327,11 @@ class MsgClient: self._sock = None +# [SECURITY AUDIT P1-2 ✅ 已修复] 线程池无队列限制 → 慢 handler 耗尽内存 +# 原问题:标准 ThreadPoolExecutor 使用无界队列,慢 handler 时任务无限堆积直至 OOM。 +# 修复:本类在 submit 时检查排队深度,超限即抛 queue.Full;MsgServer 新增 +# max_queue_size 参数(默认 1000),_handle_client 捕获 queue.Full 后拒绝并断开连接。 +# 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md class _BoundedThreadPoolExecutor(ThreadPoolExecutor): """有界线程池:提交队列有上限,满时抛 ``queue.Full``。 @@ -368,7 +387,13 @@ class MsgServer: def __init__(self, addr: str, port: int, *, workers: int = 0, recv_timeout: float = _RECV_TIMEOUT, + # [SECURITY AUDIT P1-1 ✅ 已修复] 无连接数限制 → 连接耗尽攻击 + # 原问题:服务端无并发连接上限,攻击者可耗尽文件描述符/线程资源。 + # 修复:新增 max_connections(默认 1000),_accept_client 在 + # len(_clients) >= max_connections 时直接拒绝并关闭新连接。 + # 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md max_connections: int = 1000, + # [SECURITY AUDIT P1-2 ✅ 已修复] 见上方 _BoundedThreadPoolExecutor max_queue_size: int = 1000) -> None: self.addr = addr self.port = port @@ -499,8 +524,9 @@ class MsgServer: client_sock, client_addr = servsock.accept() if client_sock.fileno() == -1: return - # 活跃连接数上限控制:超过 max_connections 直接拒绝, - # 防止连接耗尽攻击(listen backlog 只限制 SYN 队列,不限制已建立连接数) + # [SECURITY AUDIT P1-1 ✅ 已修复] 活跃连接数上限控制:超过 + # max_connections 直接拒绝,防止连接耗尽攻击(listen backlog 只限制 + # SYN 队列,不限制已建立连接数)。本段即 P1-1 的落地实现。 if len(self._clients) >= self._max_connections: try: client_sock.close() @@ -518,8 +544,9 @@ class MsgServer: text = recv(s, timeout=self._recv_timeout) except (ConnectionError, ConnectionResetError, OSError, TimeoutError, UnicodeDecodeError): - # TimeoutError: 客户端在超时时间内未发送完整消息(Slowloris 防护),关闭连接 - # UnicodeDecodeError: 防御性捕获,recv() 已包装为 ConnectionError,此处兜底 + # [SECURITY AUDIT P0-2 ✅ 已修复(第二层兜底)] UnicodeDecodeError: + # recv() 已将其包装为 ConnectionError,此处防御性捕获防止任何遗漏。 + # TimeoutError:客户端在超时时间内未发送完整消息(Slowloris 防护,P0-1),关闭连接 self._remove_client(s, socketlist) return addr = self._clients.get(s, ("unknown", 0)) @@ -537,7 +564,8 @@ class MsgServer: try: self._pool.submit(self._run_task, s, addr, text, on_message) except _queue.Full: - # 有界队列已满:慢 handler 时任务不应无限堆积,拒绝并断开该连接 + # [SECURITY AUDIT P1-2 ✅ 已修复] 有界队列已满:慢 handler 时任务不应 + # 无限堆积,拒绝并断开该连接,防止内存无限增长。 with self._lock: self._busy.discard(s) self._remove_client(s, socketlist) diff --git a/pymsg/pymsg/rpc.py b/pymsg/pymsg/rpc.py index 8ebbc5e..0d8913c 100644 --- a/pymsg/pymsg/rpc.py +++ b/pymsg/pymsg/rpc.py @@ -25,6 +25,13 @@ from . import MsgClient, MsgServer __all__ = ["RpcClient", "RpcServer", "RpcError"] +# [SECURITY AUDIT P1-5 ✅ 已修复] JSON 解析无深度/数字位数限制 → 栈溢出 / CPU 100% +# 原问题:json.loads 对深度嵌套与超大数字无限制,恶意输入可触发 RecursionError +# 栈溢出或消耗大量 CPU/内存。修复:parse_int/parse_float 钩子限制数字位数 +# <= _MAX_NUM_DIGITS(100),并捕获 RecursionError 防止深度嵌套;解析失败统一返回 +# "invalid JSON request" 不透传输入片段(见 _dispatch)。实测 10000 位整数与 +# 深度 200000 嵌套均被拒绝,服务存活。 +# 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md # 超大数字位数上限:防止超大整数/浮点(如 10^100000)消耗大量 CPU 与内存 _MAX_NUM_DIGITS = 100 @@ -46,6 +53,11 @@ def _checked_float(value: str) -> float: return float(value) +# [SECURITY AUDIT P1-3 ✅ 已修复] 异常信息直接返回客户端 → 泄露内部细节 +# 原问题:handler 抛出的异常 str(e) 含绝对路径(如 /opt/micro_servers/db/xxx.py)、 +# 堆栈等内部细节,直接回显给客户端。修复:_dispatch 返回前统一经 _sanitize_error() +# 将绝对路径替换为 ,保留业务错误语义(实测含绝对路径时仅返回 )。 +# 详见 docs/security/pymsg_SECURITY_AUDIT_20260801.md def _sanitize_error(msg: str) -> str: """脱敏异常信息中的绝对路径等内部细节,保留业务错误语义。 @@ -187,7 +199,8 @@ class RpcServer: # ── 分发与响应包装 -------------------------------------------------- def _dispatch(self, sock, addr, text): try: - # parse_int/parse_float 限制超大数字;RecursionError 防护深度嵌套 + # [SECURITY AUDIT P1-5 ✅ 已修复] parse_int/parse_float 限制超大数字; + # RecursionError 防护深度嵌套(落地实现见文件顶部 _MAX_NUM_DIGITS 注释) req = json.loads(text, parse_int=_checked_int, parse_float=_checked_float) except (json.JSONDecodeError, ValueError, RecursionError): From 685c5c1754519b79ca06c056bdd6b6da2bc3d078 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 01:26:10 +0800 Subject: [PATCH 009/196] =?UTF-8?q?refactor(libmsg):=20=E5=B0=86=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E7=BB=93=E8=AE=BA=E5=90=88=E5=85=A5=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=B9=B6=E7=B2=BE=E7=AE=80=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 libmsg 各头文件修复位置补充 [libmsg-audit <编号>] 标签(27 处), 覆盖 16 条安全审计发现(P0-1/P0-2/P1-1~3/P2-1~8/P3-1~3), 将已完成修复锚定到源码 - 从审计文档删除 335 行逐条详细发现(问题描述/修复方案/触发场景/代码块), 替换为 16 行「已解决发现」紧凑台账 + 源码定位指针 - 文档保留:严重等级表、修复进度汇总、已有安全机制、已知限制 L-1~L-12、 变更记录、审计结论 --- .../libmsg_SECURITY_AUDIT_20260806.md | 510 ++++-------------- libmsg/inc/us/AsyncQueue.hpp | 2 + libmsg/inc/us/Msg.hpp | 18 +- libmsg/inc/us/MsgServer.hpp | 54 +- libmsg/inc/us/RpcClient.hpp | 4 +- libmsg/inc/us/RpcServer.hpp | 6 +- 6 files changed, 156 insertions(+), 438 deletions(-) diff --git a/docs/security/libmsg_SECURITY_AUDIT_20260806.md b/docs/security/libmsg_SECURITY_AUDIT_20260806.md index 05781d1..fc1725e 100644 --- a/docs/security/libmsg_SECURITY_AUDIT_20260806.md +++ b/docs/security/libmsg_SECURITY_AUDIT_20260806.md @@ -10,417 +10,111 @@ ## 问题严重等级 -| 等级 | 说明 | 响应时间 | -|------|------|----------| -| P0 🔴 | 可导致服务崩溃、DoS 或远程接管、数据发往错误客户端 | 立即修复 | -| P1 🟠 | 可导致资源耗尽、信息泄露、拒绝服务 | 72小时内 | -| P2 🟡 | 健壮性/配置问题、校验缺失 | 一周内 | -| P3 🟢 | 优化建议 | 版本迭代 | +| 等级 | 说明 | 响应时间 | +| ----- | --------------------------- | ----- | +| P0 🔴 | 可导致服务崩溃、DoS 或远程接管、数据发往错误客户端 | 立即修复 | +| P1 🟠 | 可导致资源耗尽、信息泄露、拒绝服务 | 72小时内 | +| P2 🟡 | 健壮性/配置问题、校验缺失 | 一周内 | +| P3 🟢 | 优化建议 | 版本迭代 | --- ## 修复进度汇总 -| 审计轮次 | 等级 | 总数 | 已修复 | 待修复 | 修复率 | -|----------|------|------|--------|--------|--------| -| 第一轮 (2026-08-06) | P0 | 1 | 1 | 0 | 100% | -| 第一轮 (2026-08-06) | P1 | 1 | 1 | 0 | 100% | -| 第一轮 (2026-08-06) | P2 | 1 | 1 | 0 | 100% | -| 第二轮 (2026-08-07) | P0 | 1 | 1 | 0 | 100% | -| 第二轮 (2026-08-07) | P2 | 4 | 4 | 0 | 100% | -| 第三轮 (2026-08-07) | P1 | 1 | 1 | 0 | 100% | -| 第三轮 (2026-08-07) | P2 | 1 | 1 | 0 | 100% | -| 第三轮 (2026-08-07) | P3 | 1 | 1 | 0 | 100% | -| **第四轮 (2026-08-07)** | **P1** | **1** | **1** | **0** | **100%** | -| **第四轮 (2026-08-07)** | **P2** | **2** | **2** | **0** | **100%** | -| **第四轮 (2026-08-07)** | **P3** | **2** | **2** | **0** | **100%** | -| **合计** | **—** | **16** | **16** | **0** | **100%** | +| 审计轮次 | 等级 | 总数 | 已修复 | 待修复 | 修复率 | +| -------------------- | ------ | ------ | ------ | ----- | -------- | +| 第一轮 (2026-08-06) | P0 | 1 | 1 | 0 | 100% | +| 第一轮 (2026-08-06) | P1 | 1 | 1 | 0 | 100% | +| 第一轮 (2026-08-06) | P2 | 1 | 1 | 0 | 100% | +| 第二轮 (2026-08-07) | P0 | 1 | 1 | 0 | 100% | +| 第二轮 (2026-08-07) | P2 | 4 | 4 | 0 | 100% | +| 第三轮 (2026-08-07) | P1 | 1 | 1 | 0 | 100% | +| 第三轮 (2026-08-07) | P2 | 1 | 1 | 0 | 100% | +| 第三轮 (2026-08-07) | P3 | 1 | 1 | 0 | 100% | +| **第四轮 (2026-08-07)** | **P1** | **1** | **1** | **0** | **100%** | +| **第四轮 (2026-08-07)** | **P2** | **2** | **2** | **0** | **100%** | +| **第四轮 (2026-08-07)** | **P3** | **2** | **2** | **0** | **100%** | +| **合计** | **—** | **16** | **16** | **0** | **100%** | + + --- -## 🔴 P0 — 严重问题 +## 已解决发现(16 项,100% 修复) + +> 以下全部发现的根因分析、触发场景与修复说明已作为 `[libmsg-audit]` 注释合并到源码,便于在代码处直接对照审计结论。源码位置:`libmsg/inc/us/{MsgServer,Msg,Msg_Basic,RpcServer,RpcClient,AsyncQueue}.hpp`(在源码中搜索 `[libmsg-audit]` 即可定位到各修复点)。 + +| 发现 | 等级 | 轮次 | 源码位置 | CWE | 修复摘要 | +| ---- | ----- | --- | ------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------- | +| P0-1 | P0 🔴 | 第一轮 | MsgServer.hpp: broadcast / reply / \_remove_client | CWE-362 | 持 per-connection send_lock 再 close,防 fd 关闭后被 OS 复用导致数据发往错误客户端 | +| P0-2 | P0 🔴 | 第二轮 | MsgServer.hpp: \_close_all_clients | CWE-362 | 批量关闭路径同样持 send_lock 后 shutdown+close,修复第一轮只覆盖单路径的竞态回归 | +| P1-1 | P1 🟠 | 第一轮 | MsgServer.hpp: \_worker_loop / \_handle_client;Msg.hpp: recvExactDeadline | CWE-400 | workers>0 时 recv 移交 worker 线程,事件循环不再阻塞,防 Slowloris DoS | +| P1-2 | P1 🟠 | 第三轮 | MsgServer.hpp: \_accept_client | CWE-400 | 新连接设置 SO_SNDTIMEO=5s,慢客户端不读数据时 send 超时抛异常,防服务级 DoS | +| P1-3 | P1 🟠 | 第四轮 | MsgServer.hpp: ConnectionLock / reply / broadcast / \_remove_client | CWE-362 | 引入 atomic active 标志,close 前设为 false,彻底消除 reply/broadcast 与 \_remove_client 的残余竞态 | +| P2-1 | P2 🟡 | 第一轮 | RpcServer.hpp: \_sanitize_error | CWE-209 | 脱敏正则扩展为 `/\S+(?:/\S*)*`,覆盖含特殊字符路径,防内部路径泄露 | +| P2-2 | P2 🟡 | 第二轮 | Msg.hpp: pmsg_recv(int) 注释警告 | CWE-400/672 | 文档标注 SO_RCVTIMEO 前置条件,推荐 deadline 模式,防误用永久阻塞(说明并入 P2-3 文档) | +| P2-3 | P2 🟡 | 第二轮 | Msg.hpp / MsgServer.hpp / Rpc*.hpp / AsyncQueue.hpp 各入口 | CWE-20 | 新增 validateTimeout / validatePort 及全量配置边界校验,拒绝非法输入 | +| P2-4 | P2 🟡 | 第二轮 | Msg.hpp: isValidUtf8 | CWE-20 | 接收路径新增完整 UTF-8 校验,与 Python decode('utf-8') 对齐 | +| P2-5 | P2 🟡 | 第二轮 | MsgServer.hpp: 构造函数 | CWE-20 | 配置项范围校验(已并入 P2-3):recv_timeout / max_connections / max_queue_size / workers / poll_timeout / listen_backlog | +| P2-6 | P2 🟡 | 第三轮 | MsgServer.hpp: reply | CWE-703 | reply 发送失败标记 \_failed,由主循环统一清理,与 broadcast 一致 | +| P2-7 | P2 🟡 | 第四轮 | MsgServer.hpp: \_server_fd | CWE-362/662 | \_server_fd 改为 std::atomic,消除 stop() 与 \_event_loop 数据竞争 UB | +| P2-8 | P2 🟡 | 第四轮 | MsgServer.hpp: start | CWE-20 | start() 校验 on_message 非空,防 bad_function_call | +| P3-1 | P3 🟢 | 第三轮 | RpcClient.hpp: call | CWE-20 | 调用 as_bool() 前校验 success 为 bool,类型不符抛 RpcError | +| P3-2 | P3 🟢 | 第四轮 | RpcServer.hpp: \_dispatch | CWE-209 | \_route 缺失 vs 类型不符返回不同错误信息 | +| P3-3 | P3 🟢 | 第四轮 | MsgServer.hpp: \_accept_client | — | setsockopt 移到加入 \_clients 之前,先完成 socket 配置再暴露给其他线程 | -### P0-1: broadcast/reply 竞态条件导致数据发送到错误客户端 ✅ 已修复(第一轮) -- **CWE**: CWE-362 (Race Condition) -- **OWASP**: A01:2021 -- **位置**: `MsgServer.hpp:228`(原始行号) -- **置信度**: 80% - -**问题描述**: -`broadcast()` 在 `_clients_mtx` 保护下拷贝 fd + send_lock 列表后释放锁,再无锁执行 `pmsg_send(fd, msg)`。此窗口期内 `_remove_client()` 可关闭该 fd,OS 可将其分配给新连接。`broadcast()`/`reply()` 随后向复用的 fd 发送数据,导致消息到达错误的客户端。`_remove_client()` 原实现关闭 fd 前不获取 per-connection `send_lock`,因此 `send_lock` 无法阻止此竞态。 - -**修复方案**(commit `e92ef05`): -`_remove_client()` 先从 `_send_locks` 取出 `send_lock` 并从 map 删除,持有 `send_lock` 后再执行 `::close(fd)`,确保关闭与正在进行的发送操作互斥。 - ---- - -### P0-2: _close_all_clients() 批量关闭路径存在同类竞态回归 ✅ 已修复(第二轮) - -- **CWE**: CWE-362 (Race Condition) -- **OWASP**: A01:2021 -- **位置**: `MsgServer.hpp::_close_all_clients()`(析构/stop路径) -- **置信度**: 90% - -**问题描述**: -第一轮修复只覆盖了单客户端移除路径 `_remove_client()`,但批量关闭路径 `_close_all_clients()`(在 `start()` 异常退出或正常退出时调用)直接遍历 `_clients` 并 `::close(fd)`,**未持有每连接 `send_lock`**。在竞态窗口内,reply/broadcast 可能在 `_clients_mtx` 释放后、`close(fd)` 执行前获取到 send_lock,或 `close(fd)` 后 OS 复用 fd 给新连接,导致数据发送到错误客户端。 - -**触发场景**: -1. 服务调用 `stop()` 或 `start()` 异常退出,进入 `_close_all_clients()` -2. 另一线程正在调用 `reply()`/`broadcast()`,已在 `_clients_mtx` 下拷贝了 send_lock 但尚未持有 send_lock 发送数据 -3. `_close_all_clients()` 无锁直接 close(fd),OS 复用 fd -4. 发送操作向复用的 fd 写入数据,到达错误客户端 - -**修复方案**(第二轮修复): -`_close_all_clients()` 改为与 `_remove_client()` 一致的安全模式:先在 `_clients_mtx` 保护下收集所有 (fd, send_lock) 对并清空 map,释放锁后对每个 fd 持有对应的 send_lock 再执行 `shutdown()+close()`: - -```cpp -void _close_all_clients() { - std::vector>> to_close; - { - std::lock_guard clk(_clients_mtx); - to_close.reserve(_clients.size()); - for (auto& [fd, addr] : _clients) { - auto it = _send_locks.find(fd); - if (it != _send_locks.end()) { - to_close.emplace_back(fd, it->second); - } else { - to_close.emplace_back(fd, nullptr); - } - } - _clients.clear(); - _send_locks.clear(); - } - for (auto& [fd, send_lock] : to_close) { - if (send_lock) { - std::lock_guard slk(*send_lock); - ::shutdown(fd, SHUT_RDWR); - ::close(fd); - } else { - ::shutdown(fd, SHUT_RDWR); - ::close(fd); - } - } - // ... 清理 _busy/_failed -} -``` - ---- - -## 🟠 P1 — 高危问题 - -### P1-1: 事件循环顺序阻塞接收导致 DoS(Slowloris 变体)✅ 已修复(第一轮) - -- **CWE**: CWE-400 (Uncontrolled Resource Consumption) -- **OWASP**: A05:2021 -- **位置**: `MsgServer.hpp:424`(原始行号) -- **置信度**: 80% - -**问题描述**: -`_event_loop()` 对每个就绪 fd 顺序调用 `_handle_client()`,后者在**事件循环主线程**中阻塞调用 `pmsg_recv(fd, recv_timeout)`(默认 10 秒)。线程池仅处理回调执行,不处理消息接收,即使配置了 `workers > 0` 也无法缓解。攻击者用 `max_connections`(默认 1000)个连接各发送不完整帧头(如 3 字节),可使事件循环阻塞最长 `1000 × 10 = 10000` 秒(约 2.8 小时),期间服务完全无法处理任何新连接或已有消息。 - -**修复方案**(commit `e92ef05`): -在 `workers > 0` 模式下,`_handle_client()` 不再在事件循环线程调用 `pmsg_recv()`,而是提交 `needs_recv=true` 的任务给 worker 线程,由 worker 线程负责完整的 recv + callback 流程。同步模式(`workers=0`)行为不变。 - ---- - -## 🟡 P2 — 中危问题 - -### P2-1: 错误信息路径脱敏正则不完整,可能泄露内部路径 ✅ 已修复(第一轮) - -- **CWE**: CWE-209 (Information Exposure Through an Error Message) -- **OWASP**: A04:2021 -- **位置**: `RpcServer.hpp:184`(原始行号) -- **置信度**: 75% - -**问题描述**: -`RpcServer::_sanitize_error()` 使用正则 `/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*` 替换绝对路径,含空格、括号、`@`、`~` 等特殊字符的路径不被匹配,会原文返回给远程客户端。 - -**修复方案**(commit `e92ef05`): -正则扩展为 `/\S+(?:/\S*)*`,覆盖所有非空白字符路径。 - ---- - -### P2-2: pmsg_recv(int) 依赖隐式SO_RCVTIMEO前置条件,误用可导致永久阻塞 ✅ 已修复(第二轮) - -- **CWE**: CWE-400 (Uncontrolled Resource Consumption) / CWE-672 (Operation on a Resource after Expiration) -- **位置**: `Msg.hpp` `pmsg_recv(int sockfd)` -- **置信度**: 70% - -**问题描述**: -单参数版本 `pmsg_recv(int sockfd)`(阻塞模式)依赖调用方**预先**通过 `setsockopt(SO_RCVTIMEO)` 设置接收超时,函数本身既不设置超时,也不检查超时是否已设置。如果外部用户直接使用此 API 而忘记设置超时,`recv()` 会永久阻塞,构成拒绝服务风险。虽然所有内部调用点(`MsgClient::listen()`)都正确设置了超时,但作为公共 API 存在误用风险。 - -**修复方案**(第二轮修复): -1. 在 Doxygen 注释中明确标注前置条件,推荐使用 deadline 模式 `pmsg_recv(sockfd, timeout_sec)` -2. 统一了接收路径的 UTF-8 校验(见 P2-5) - ---- - -### P2-3: 公共API参数无边界校验,异常输入可导致未定义行为 ✅ 已修复(第二轮) - -- **CWE**: CWE-20 (Improper Input Validation) -- **位置**: `Msg.hpp`、`MsgServer.hpp`、`AsyncQueue.hpp`、`RpcClient.hpp`、`RpcServer.hpp` 各构造函数和公共方法 -- **置信度**: 85% - -**问题描述**: -多数公共入口函数未校验参数合法性: -- `timeout_sec` 传入 0 或负数时,`deadline` 立即到期导致异常行为;传入极大值(如 > 1小时)可绕过 Slowloris 防护 -- `port` 传入 0 或 >65535 时,`htons()` 截断产生错误端口 -- `MsgServerConfig` 中 `workers`、`max_connections`、`max_queue_size`、`poll_timeout`、`recv_timeout`、`listen_backlog` 无范围校验,传入负值或极大值可导致内存分配失败、CPU 100% 或绕过安全限制 -- `AsyncQueueConfig` 中 `batch_size`、`flush_interval`、`poll_timeout` 无校验,`batch_size=0` 导致死循环或异常 -- `RpcClient` 的 `timeout`、`retry_interval` 无校验 -- `RpcServer::add_route()` 接受空字符串路由名和空 handler,导致后续调用崩溃 -- `AsyncQueue` 构造接受 null handler,调用时崩溃 - -**修复方案**(第二轮修复): -新增 `detail::validateTimeout()` 和 `detail::validatePort()` 辅助函数,并在以下入口点添加边界校验: - -| 位置 | 校验项 | -|------|--------| -| `detail::recvFrameDeadline()` | `0 < timeout_sec ≤ 300` 秒 | -| `detail::openConnection()` | `1 ≤ port ≤ 65535`,`0 < timeout ≤ 300` 秒 | -| `MsgClient::MsgClient()` | port 范围 | -| `MsgClient::call()` | timeout、retry_interval 范围 | -| `MsgServer::MsgServer()` | port/workers/recv_timeout/max_connections/max_queue_size/listen_backlog/poll_timeout 全部校验 | -| `AsyncQueue::AsyncQueue()` | handler 非空、batch_size>0、flush_interval/poll_timeout 合理范围 | -| `RpcClient::RpcClient()` | timeout、retry_interval 范围 | -| `RpcServer::add_route()` | name 非空、handler 非空 | - ---- - -### P2-4: 消息载荷缺少UTF-8合法性校验 ✅ 已修复(第二轮) - -- **CWE**: CWE-20 (Improper Input Validation) -- **位置**: `Msg.hpp` `pmsg_recv()` 两个重载版本 -- **置信度**: 65% - -**问题描述**: -Python 版 `pymsg.recv()` 在接收载荷后调用 `payload.decode("utf-8")` 进行 UTF-8 合法性校验,非法字节序列会抛出 `UnicodeDecodeError` 并被包装为 `ConnectionError`。C++ 版直接将原始字节存入 `std::string` 返回,不校验 UTF-8 合法性,可能导致上层 JSON 解析器或业务逻辑出现未定义行为(如 null 字节截断、非法序列解析异常、安全绕过)。 - -**修复方案**(第二轮修复): -1. 新增 `detail::isValidUtf8()` 函数,实现完整的 UTF-8 校验: - - 正确识别 1/2/3/4 字节序列 - - 拒绝 overlong 编码(如 0xC0 0x80 表示 NUL) - - 拒绝 UTF-16 代理对(0xD800-0xDFFF) - - 拒绝超 U+10FFFF 的编码 - - 拒绝非法续字节、截断序列 -2. `pmsg_recv(int)` 和 `pmsg_recv(int, double)` 在接收完 payload 后调用 isValidUtf8 校验,非法载荷抛出 `MsgConnectionException("invalid UTF-8 payload")` 断开连接 - ---- - -### P2-5: 缺少参数校验导致无效配置可绕过安全限制 ✅ 已修复(第二轮) - -- **CWE**: CWE-20 (Improper Input Validation) -- **说明**: 此问题已合并入 P2-3 修复。MsgServer 构造函数现在对所有配置项实施范围校验: - - `recv_timeout`: (0, 300] 秒,防止传入超大值绕过 Slowloris 防护 - - `max_connections`: [1, 100000],防止 0 或负值导致所有连接被拒绝或无限制 - - `max_queue_size`: [1, 1000000],防止 0 或负值导致无法提交任务 - - `workers`: [0, 256],防止负值或过大线程数耗尽资源 - - `poll_timeout`: (0, 5] 秒,防止 0 导致 select 忙轮询 CPU 100% - - `listen_backlog`: [1, 4096] - ---- - -## 🟠 P1 — 高危问题(第三轮) - -### P1-2: 服务端 socket 未设置发送超时,慢客户端不读数据可致 send 永久阻塞(DoS) ✅ 已修复(第三轮) - -- **CWE**: CWE-400 (Uncontrolled Resource Consumption) -- **OWASP**: A05:2021 -- **位置**: `MsgServer.hpp::_accept_client()`(新连接只设 TCP_NODELAY,未设 SO_SNDTIMEO) -- **置信度**: 85% - -**问题描述**: -服务端每个客户端 socket 在 `_accept_client()` 中仅设置了 `TCP_NODELAY`,**未设置发送超时 `SO_SNDTIMEO`**。若攻击者连接后停止读取数据,发送缓冲区会被填满,此时 `reply()`/`broadcast()` 调用的 `pmsg_send()` → `sendAll()` 中的 `send()` 因无法写入而**永久阻塞**(无超时则不会返回 EAGAIN)。 - -- **同步模式(workers=0)**: `reply()`/`broadcast()` 在主线程(事件循环线程)内执行,send 永久阻塞即卡死整个事件循环,服务完全无法处理任何连接 → **服务级 DoS**。 -- **线程池模式(workers>0)**: 阻塞的 send 占用 worker 线程,攻击者可用少量慢连接耗尽全部 worker。 - -**触发场景**: -1. 攻击者建立连接但不调用 recv 读取数据 -2. 服务端向其 reply() 大量数据(如日志/广播),发送缓冲区填满 -3. `send()` 永久阻塞,卡死主线程(同步模式)或占用 worker 线程 - -**修复方案**(第三轮修复): -在 `_accept_client()` 中对新连接设置 `SO_SNDTIMEO = 5.0` 秒。发送超时后 `sendAll()` 检测到 `EAGAIN/EWOULDBLOCK` 抛 `MsgTimeoutException`,被 `reply()`/`broadcast()` 捕获并标记为 `_failed`,由主循环统一清理连接(配合 P2-6 修复)。 - -## 🟡 P2 — 中危问题(第三轮) - -### P2-6: reply() 发送失败静默忽略且不标记 failed,连接残留不清理 ✅ 已修复(第三轮) - -- **CWE**: CWE-703 (Improper Check or Handling of Exceptional Conditions) -- **位置**: `MsgServer.hpp::reply()`(`catch (const MsgException&)` 分支) -- **置信度**: 80% - -**问题描述**: -`broadcast()` 发送失败时会将该 fd 加入 `_failed` 集合,由主循环统一清理;而 `reply()` 发送失败时仅 `catch` 静默忽略,**不标记 `_failed`**。若 reply 的目标连接已断开或发送超时,该连接会一直残留在 `_clients` 中,直到主循环 select 检测到读端异常才被清理,造成资源滞留和清理路径不一致。 - -**修复方案**(第三轮修复): -`reply()` 的失败分支改为与 `broadcast()` 一致:捕获 `MsgException` 后向 `_failed` 集合插入该 fd,由主循环 `_reap_failed()` 统一清理。 - -## 🟢 P3 — 优化建议(第三轮) - -### P3-1: RpcClient::call() 未校验 success 字段类型,非 bool 时抛未捕获的 boost 异常 ✅ 已修复(第三轮) - -- **CWE**: CWE-20 (Improper Input Validation) -- **位置**: `RpcClient.hpp::call()`(`obj.at("success").as_bool()`) -- **置信度**: 70% - -**问题描述**: -`call()` 已检查响应包含 `success` 字段,但未校验其**类型**。若远端返回 `{"success": "false"}`(字符串)等非 bool 值,`as_bool()` 会抛出未捕获的 `boost::system::system_error`,而非统一的 `RpcError`,导致调用方无法按既定异常层次处理,且错误信息可能泄露内部细节。 - -**修复方案**(第三轮修复): -调用 `as_bool()` 前先检查 `success.is_bool()`,类型不符时抛出 `RpcError("非法 RPC 响应(success 字段不是 bool)")`,与其余非法响应处理保持一致。 - ---- - -## 🟠 P1 — 高危问题(第四轮) - -### P1-3: reply/broadcast 与 _remove_client 之间存在残余竞态,fd 复用可致数据发往错误客户端 ✅ 已修复(第四轮) - -- **CWE**: CWE-362 (Race Condition) -- **OWASP**: A01:2021 -- **位置**: `MsgServer.hpp` `reply()` / `broadcast()` / `_remove_client()` -- **置信度**: 85% - -**问题描述**: -第一轮 P0-1 修复确保了"发送期间不会被 close"(reply/broadcast 先获取 send_lock → _remove_client 等待),但未覆盖反向场景:**_remove_client 先获取 send_lock 并 close(fd) 后,reply/broadcast 后获取 send_lock 时 fd 已关闭**。如果 OS 将该 fd 复用给新 accept 的连接,reply/broadcast 的 `pmsg_send()` 会向复用的 fd 写入数据,导致消息到达错误客户端。 - -**触发场景**: -1. reply/broadcast 在 `_clients_mtx` 下获取了 `send_lock` 的 shared_ptr 副本 -2. 释放 `_clients_mtx` -3. `_remove_client()` 获取 `_clients_mtx`,erase fd,获取 `send_lock` 副本,释放 `_clients_mtx` -4. `_remove_client()` 先于 reply/broadcast 获取 `send_lock` -5. `_remove_client()` `close(fd)`,释放 `send_lock` -6. OS 将 fd 复用给新 accept 的连接 -7. reply/broadcast 获取 `send_lock`,调用 `pmsg_send(fd, msg)` → 数据到达新连接 - -步骤 2-4 的窗口期虽然很短(仅几条指令),但在高并发场景(大量 broadcast + 连接断开同时发生)下可被触发。 - -**修复方案**(第四轮修复): -引入 `ConnectionLock` 结构,将 `send_lock`(mutex)和 `active`(atomic\)组合在一起: - -```cpp -struct ConnectionLock { - std::mutex send_mtx; - std::atomic active{true}; -}; -``` - -- `reply()`/`broadcast()` 在获取 `send_mtx` 后检查 `active`:若为 false 则放弃发送(fd 已被关闭) -- `_remove_client()`/`_close_all_clients()` 在获取 `send_mtx` 后先设置 `active=false`,再 `shutdown()`+`close(fd)` - -这样无论谁先获取锁: -- 若 reply/broadcast 先获取锁:`active=true`,正常发送,`_remove_client` 等待发送完成后再 close -- 若 `_remove_client` 先获取锁:设置 `active=false`,close(fd),reply/broadcast 后获取锁时检查 `active=false` 直接 return - ---- - -## 🟡 P2 — 中危问题(第四轮) - -### P2-7: _server_fd 非原子访问构成数据竞争(UB) ✅ 已修复(第四轮) - -- **CWE**: CWE-362 (Race Condition) / CWE-662 (Improper Synchronization) -- **位置**: `MsgServer.hpp` `_server_fd` 成员 -- **置信度**: 90% - -**问题描述**: -`_server_fd` 是普通 `int`,在主线程(`_event_loop`)读、`stop()` 线程写,构成 C++ 标准定义的数据竞争(Undefined Behavior)。虽然在实际 64 位系统上 int 读写通常是原子的(最坏情况读取旧值导致 accept 失败),但严格来说是 UB,编译器优化可能产生意外行为。 - -**修复方案**(第四轮修复): -将 `_server_fd` 改为 `std::atomic`,所有读写使用 `.load()` / `.store()` / `.exchange()`。`stop()` 使用 `exchange(-1)` 原子地认领 fd 并关闭。 - ---- - -### P2-8: start() 未校验 on_message 回调非空 ✅ 已修复(第四轮) - -- **CWE**: CWE-20 (Improper Input Validation) -- **位置**: `MsgServer.hpp` `start()` -- **置信度**: 85% - -**问题描述**: -`start(on_message)` 接受 `std::function` 参数但未校验是否为空。若传入空回调(默认构造的 `std::function`),后续 `_on_message(fd, addr, msg)` 会抛出 `std::bad_function_call`,该异常在 worker 线程中被 catch 但在同步模式下会导致连接断开;在 `_dispatch`(RpcServer)中会导致单个请求失败。虽然不会崩溃服务,但行为不符合预期且错误信息不明确。 - -**修复方案**(第四轮修复): -`start()` 开头添加校验:`if (!on_message) throw MsgException("on_message callback must not be null");` - ---- - -## 🟢 P3 — 优化建议(第四轮) - -### P3-2: RpcServer _route 非 string 类型时错误信息不准确 ✅ 已修复(第四轮) - -- **CWE**: CWE-209 (Information Exposure Through an Error Message) -- **位置**: `RpcServer.hpp` `_dispatch()` -- **置信度**: 70% - -**问题描述**: -`_dispatch()` 中检查 `_route` 字段时,若字段存在但不是 string 类型(如数字、数组),错误信息统一返回 `"missing _route"`,误导客户端以为字段缺失而非类型错误。 - -**修复方案**(第四轮修复): -区分两种情况:字段不存在返回 `"missing _route"`,类型不符返回 `"_route must be a string"`。 - -### P3-3: _accept_client 中 setsockopt 在加入 _clients 之后执行 ✅ 已修复(第四轮) - -- **位置**: `MsgServer.hpp` `_accept_client()` -- **置信度**: 60% - -**问题描述**: -原实现先将 `client_fd` 加入 `_clients` 和 `_send_locks`(持锁),释放锁后再设置 `TCP_NODELAY` 和 `SO_SNDTIMEO`。虽然新连接此时尚未收到消息,不会有 reply/broadcast 发送数据,但最佳实践是先完成所有 socket 配置再暴露给其他线程。 - -**修复方案**(第四轮修复): -调整顺序:先设置 `TCP_NODELAY` 和 `SO_SNDTIMEO`,再持锁加入 `_clients`。 --- ## ✅ 已有安全机制(更新后) -| 机制 | 位置 | 说明 | -|------|------|------| -| 消息大小上限 64 MiB | `Msg_Basic.hpp` `kMaxMsgSize` | 防止超大消息耗尽内存 | -| deadline 模式 recv 超时 | `Msg.hpp` `recvFrameDeadline` | header+payload 共享总超时,防 Slowloris | -| 入口参数边界校验 | `Msg.hpp/MsgServer.hpp/AsyncQueue.hpp/Rpc*.hpp` | timeout/port/配置项全范围校验,拒绝非法输入 | -| UTF-8 载荷校验 | `Msg.hpp` `isValidUtf8()` | 与 Python decode('utf-8') 对齐,拒绝非法字节序列 | -| 最大连接数限制 | `MsgServer.hpp` `max_connections=1000` | 超限拒绝新连接 | -| 有界任务队列 | `MsgServer.hpp` `max_queue_size=1000` | 队列满时断开连接,防 OOM | -| per-connection 发送锁 + active 标志 | `MsgServer.hpp` `ConnectionLock` | reply/broadcast 线程安全防帧交错 + active 检查防 fd 复用后发送 | -| 服务端发送超时 SO_SNDTIMEO | `MsgServer.hpp` `_accept_client()` | 慢客户端不读数据时 send 超时抛异常,防拒绝服务(P1-2) | -| reply() 失败标记 failed | `MsgServer.hpp` `reply()` | 与 broadcast 一致,失败连接由主循环清理(P2-6) | -| RpcClient success 类型校验 | `RpcClient.hpp` `call()` | 非 bool 时抛 RpcError,而非未捕获的 boost 异常(P3-1) | -| _close_all_clients 批量关闭也持锁 | `MsgServer.hpp` `_close_all_clients()` | 修复批量关闭路径的竞态(P0-2) | -| **新增**: ConnectionLock active 标志 | `MsgServer.hpp` `ConnectionLock::active` | reply/broadcast 获取锁后检查 active,彻底消除残余竞态(P1-3) | -| **新增**: _server_fd atomic 访问 | `MsgServer.hpp` `std::atomic _server_fd` | 消除 stop() 与 _event_loop 的数据竞争 UB(P2-7) | -| **新增**: start() 空回调校验 | `MsgServer.hpp` `start()` | 防止 bad_function_call 异常(P2-8) | -| **新增**: _accept_client 先配置后注册 | `MsgServer.hpp` `_accept_client()` | socket 选项在加入 _clients 前设置完整(P3-3) | -| 同一连接消息串行 | `MsgServer.hpp` `_busy` 集合 | 回调执行中暂停该连接 select 监听 | -| JSON 解析深度限制 | `RpcServer.hpp` `opts.max_depth=32` | 防深度嵌套 JSON 导致栈溢出 | -| 异常信息脱敏 | `RpcServer.hpp` `_sanitize_error()` | 过滤绝对路径,完整异常由 on_error 钩子记录 | -| RpcClient 响应格式校验 | `RpcClient.hpp` | 校验 success 字段,非法响应抛 RpcError | -| worker 线程异常隔离 | `MsgServer.hpp` `_worker_loop()` | handler 异常仅断开该连接,不影响服务 | -| select FD_SETSIZE 检查 | `MsgServer.hpp` `_accept_client()` | fd >= FD_SETSIZE 时拒绝连接,防 select 越界 | -| TCP_NODELAY | `MsgServer.hpp` `_accept_client()` | 新连接禁用 Nagle 算法,降低小包延迟 | -| MSG_NOSIGNAL 兼容 | `Msg.hpp` | macOS 兼容,防止 send 到关闭连接触发 SIGPIPE | -| AsyncQueue 背压保护 | `AsyncQueue.hpp` `max_queue_size` | 队列满时丢最旧数据,防内存无限增长 | -| AsyncQueue fail-safe | `AsyncQueue.hpp` | handler 异常触发 on_error,不传播到业务线程 | +| 机制 | 位置 | 说明 | +| -------------------------------- | ----------------------------------------------- | ---------------------------------------------- | +| 消息大小上限 64 MiB | `Msg_Basic.hpp` `kMaxMsgSize` | 防止超大消息耗尽内存 | +| deadline 模式 recv 超时 | `Msg.hpp` `recvFrameDeadline` | header+payload 共享总超时,防 Slowloris | +| 入口参数边界校验 | `Msg.hpp/MsgServer.hpp/AsyncQueue.hpp/Rpc*.hpp` | timeout/port/配置项全范围校验,拒绝非法输入 | +| UTF-8 载荷校验 | `Msg.hpp` `isValidUtf8()` | 与 Python decode('utf-8') 对齐,拒绝非法字节序列 | +| 最大连接数限制 | `MsgServer.hpp` `max_connections=1000` | 超限拒绝新连接 | +| 有界任务队列 | `MsgServer.hpp` `max_queue_size=1000` | 队列满时断开连接,防 OOM | +| per-connection 发送锁 + active 标志 | `MsgServer.hpp` `ConnectionLock` | reply/broadcast 线程安全防帧交错 + active 检查防 fd 复用后发送 | +| 服务端发送超时 SO_SNDTIMEO | `MsgServer.hpp` `_accept_client()` | 慢客户端不读数据时 send 超时抛异常,防拒绝服务(P1-2) | +| reply() 失败标记 failed | `MsgServer.hpp` `reply()` | 与 broadcast 一致,失败连接由主循环清理(P2-6) | +| RpcClient success 类型校验 | `RpcClient.hpp` `call()` | 非 bool 时抛 RpcError,而非未捕获的 boost 异常(P3-1) | +| \_close_all_clients 批量关闭也持锁 | `MsgServer.hpp` `_close_all_clients()` | 修复批量关闭路径的竞态(P0-2) | +| **新增**: ConnectionLock active 标志 | `MsgServer.hpp` `ConnectionLock::active` | reply/broadcast 获取锁后检查 active,彻底消除残余竞态(P1-3) | +| **新增**: \_server_fd atomic 访问 | `MsgServer.hpp` `std::atomic _server_fd` | 消除 stop() 与 \_event_loop 的数据竞争 UB(P2-7) | +| **新增**: start() 空回调校验 | `MsgServer.hpp` `start()` | 防止 bad_function_call 异常(P2-8) | +| **新增**: \_accept_client 先配置后注册 | `MsgServer.hpp` `_accept_client()` | socket 选项在加入 \_clients 前设置完整(P3-3) | +| 同一连接消息串行 | `MsgServer.hpp` `_busy` 集合 | 回调执行中暂停该连接 select 监听 | +| JSON 解析深度限制 | `RpcServer.hpp` `opts.max_depth=32` | 防深度嵌套 JSON 导致栈溢出 | +| 异常信息脱敏 | `RpcServer.hpp` `_sanitize_error()` | 过滤绝对路径,完整异常由 on_error 钩子记录 | +| RpcClient 响应格式校验 | `RpcClient.hpp` | 校验 success 字段,非法响应抛 RpcError | +| worker 线程异常隔离 | `MsgServer.hpp` `_worker_loop()` | handler 异常仅断开该连接,不影响服务 | +| select FD_SETSIZE 检查 | `MsgServer.hpp` `_accept_client()` | fd >= FD_SETSIZE 时拒绝连接,防 select 越界 | +| TCP_NODELAY | `MsgServer.hpp` `_accept_client()` | 新连接禁用 Nagle 算法,降低小包延迟 | +| MSG_NOSIGNAL 兼容 | `Msg.hpp` | macOS 兼容,防止 send 到关闭连接触发 SIGPIPE | +| AsyncQueue 背压保护 | `AsyncQueue.hpp` `max_queue_size` | 队列满时丢最旧数据,防内存无限增长 | +| AsyncQueue fail-safe | `AsyncQueue.hpp` | handler 异常触发 on_error,不传播到业务线程 | --- ## 待修复 / 已知限制(架构层面,非代码缺陷) -| # | 问题 | 优先级 | 说明 | -|---|------|--------|------| -| L-1 | 无认证/鉴权机制 | P0(已缓解) | 仅依赖绑定 127.0.0.1 网络层隔离,任意本地进程可调用所有路由。设计如此,内网微服务信任模型。 | -| L-2 | 无 TLS 加密传输 | P1 | 内网明文 TCP,进程间通信可被本地 root 用户抓包。如跨主机部署需增加 TLS 层。 | -| L-3 | 同步模式(workers=0)仍在主线程阻塞 recv | P2 | 无线程池时无法并发,Slowloris 防护依赖 recv_timeout 截止时间(10秒)。建议生产环境使用 workers>0。 | -| L-4 | select 模型上限 FD_SETSIZE(通常 1024) | P2 | 高并发场景建议改用 epoll/kqueue,或在 fd 超限时告警/拒绝。已在 _accept_client 加检查防止越界。 | -| L-5 | 安全事件无结构化日志 | P3 | 超时、超大消息、UTF-8 校验失败、JSON 解析失败等仅 fprintf(stderr),无可观测性。建议接入结构化日志。 | -| L-6 | pmsg_recv(int) 单参数版本仍依赖外部超时设置 | P3 | 已添加文档警告,推荐使用 deadline 模式。考虑未来版本弃用单参数版本或内部设置安全默认超时。 | -| L-7 | AsyncQueue 关闭时 flush 失败数据只记 on_error | P3 | _flush_all() 中 handler 失败后数据不重试,符合非关键路径设计,但应确保 on_error 有持久化/告警机制。 | -| L-8 | 半连接(只发部分帧头)仍可占用一个 worker 线程 recv_timeout 秒 | P2 | workers>0 时消息接收在 worker 线程完成,慢连接可占用 worker 最多 recv_timeout(默认10秒)。数量受 max_connections 与 recv_timeout 有界约束,已缓解。可用更小 recv_timeout 或独立接收线程池进一步收紧。 | -| L-9 | pmsg_send 发送端不校验 UTF-8,与 Python 版 encode('utf-8') 不对称 | P3 | 接收端已在 pmsg_recv 校验 UTF-8(P2-4),非法载荷会被远端断开。发送端未校验,若业务传入二进制可能被远端拒绝。若存在纯二进制传输场景,发送端校验会破坏兼容,故暂不强制,仅记录。 | -| L-10 | pmsg_send 公共 API 依赖 SO_SNDTIMEO | P3 | sendAll() 在 send() 返回 EAGAIN 时抛超时异常,但前提是 socket 已设置 SO_SNDTIMEO。已添加文档警告,MsgClient 和 MsgServer 内部均已设置。直接使用 pmsg_send 的外部调用方需自行设置。 | -| L-11 | MsgServer 未设置 TCP keepalive | P3 | 客户端崩溃(不发送 FIN)时连接会残留直到 select 检测异常或 send 失败。建议生产环境设置 SO_KEEPALIVE 以更快检测死连接。 | -| L-12 | _remove_client 同步模式下无总超时限制 | P3 | 同步模式下 _handle_client 中 pmsg_recv 有 recv_timeout 限制,但回调执行无超时。慢回调会阻塞事件循环。建议将慢操作移至线程池模式或业务层自行限时。 | +| # | 问题 | 优先级 | 说明 | +| ---- | ----------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| L-1 | 无认证/鉴权机制 | P0(已缓解) | 仅依赖绑定 127.0.0.1 网络层隔离,任意本地进程可调用所有路由。设计如此,内网微服务信任模型。 | +| L-2 | 无 TLS 加密传输 | P1 | 内网明文 TCP,进程间通信可被本地 root 用户抓包。如跨主机部署需增加 TLS 层。 | +| L-3 | 同步模式(workers=0)仍在主线程阻塞 recv | P2 | 无线程池时无法并发,Slowloris 防护依赖 recv_timeout 截止时间(10秒)。建议生产环境使用 workers>0。 | +| L-4 | select 模型上限 FD_SETSIZE(通常 1024) | P2 | 高并发场景建议改用 epoll/kqueue,或在 fd 超限时告警/拒绝。已在 \_accept_client 加检查防止越界。 | +| L-5 | 安全事件无结构化日志 | P3 | 超时、超大消息、UTF-8 校验失败、JSON 解析失败等仅 fprintf(stderr),无可观测性。建议接入结构化日志。 | +| L-6 | pmsg_recv(int) 单参数版本仍依赖外部超时设置 | P3 | 已添加文档警告,推荐使用 deadline 模式。考虑未来版本弃用单参数版本或内部设置安全默认超时。 | +| L-7 | AsyncQueue 关闭时 flush 失败数据只记 on_error | P3 | \_flush_all() 中 handler 失败后数据不重试,符合非关键路径设计,但应确保 on_error 有持久化/告警机制。 | +| L-8 | 半连接(只发部分帧头)仍可占用一个 worker 线程 recv_timeout 秒 | P2 | workers>0 时消息接收在 worker 线程完成,慢连接可占用 worker 最多 recv_timeout(默认10秒)。数量受 max_connections 与 recv_timeout 有界约束,已缓解。可用更小 recv_timeout 或独立接收线程池进一步收紧。 | +| L-9 | pmsg_send 发送端不校验 UTF-8,与 Python 版 encode('utf-8') 不对称 | P3 | 接收端已在 pmsg_recv 校验 UTF-8(P2-4),非法载荷会被远端断开。发送端未校验,若业务传入二进制可能被远端拒绝。若存在纯二进制传输场景,发送端校验会破坏兼容,故暂不强制,仅记录。 | +| L-10 | pmsg_send 公共 API 依赖 SO_SNDTIMEO | P3 | sendAll() 在 send() 返回 EAGAIN 时抛超时异常,但前提是 socket 已设置 SO_SNDTIMEO。已添加文档警告,MsgClient 和 MsgServer 内部均已设置。直接使用 pmsg_send 的外部调用方需自行设置。 | +| L-11 | MsgServer 未设置 TCP keepalive | P3 | 客户端崩溃(不发送 FIN)时连接会残留直到 select 检测异常或 send 失败。建议生产环境设置 SO_KEEPALIVE 以更快检测死连接。 | +| L-12 | \_remove_client 同步模式下无总超时限制 | P3 | 同步模式下 \_handle_client 中 pmsg_recv 有 recv_timeout 限制,但回调执行无超时。慢回调会阻塞事件循环。建议将慢操作移至线程池模式或业务层自行限时。 | --- @@ -430,32 +124,32 @@ struct ConnectionLock { **代码变更文件**: -| 文件 | 变更内容 | -|------|----------| +| 文件 | 变更内容 | +| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [MsgServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/MsgServer.hpp) | **P1修复**: 引入 `ConnectionLock` 结构(mutex + atomic active),`reply()`/`broadcast()` 获取锁后检查 `active`,`_remove_client()`/`_close_all_clients()` close 前设置 `active=false`,彻底消除残余竞态(P1-3);**P2修复**: `_server_fd` 改为 `std::atomic`,所有访问使用 `.load()`/`.store()`/`.exchange()`(P2-7);**P2修复**: `start()` 校验 `on_message` 非空(P2-8);**P3修复**: `_accept_client()` 中 setsockopt 移到加入 `_clients` 之前(P3-3) | -| [RpcServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcServer.hpp) | **P3修复**: `_dispatch()` 区分 `_route` 字段缺失和类型不符的错误信息(P3-2) | -| [Msg.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/Msg.hpp) | **文档**: `pmsg_send` 添加 SO_SNDTIMEO 前置条件警告(L-10) | +| [RpcServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcServer.hpp) | **P3修复**: `_dispatch()` 区分 `_route` 字段缺失和类型不符的错误信息(P3-2) | +| [Msg.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/Msg.hpp) | **文档**: `pmsg_send` 添加 SO_SNDTIMEO 前置条件警告(L-10) | ### 2026-08-07(第三轮审计修复) **代码变更文件**: -| 文件 | 变更内容 | -|------|----------| +| 文件 | 变更内容 | +| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | [MsgServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/MsgServer.hpp) | **P1修复**: `_accept_client()` 对新连接设置 `SO_SNDTIMEO=5s`,慢客户端不读数据时 send 超时抛异常,防服务级 DoS(P1-2);**P2修复**: `reply()` 发送失败标记 `_failed`,由主循环清理连接(P2-6) | -| [RpcClient.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcClient.hpp) | **P3修复**: `call()` 调用 `as_bool()` 前校验 `success` 字段为 bool,类型不符抛 RpcError(P3-1) | +| [RpcClient.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcClient.hpp) | **P3修复**: `call()` 调用 `as_bool()` 前校验 `success` 字段为 bool,类型不符抛 RpcError(P3-1) | ### 2026-08-07(第二轮审计修复) **代码变更文件**: -| 文件 | 变更内容 | -|------|----------| -| [Msg.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/Msg.hpp) | 新增 isValidUtf8()/validateTimeout()/validatePort();recvFrameDeadline 添加参数校验和UTF-8校验;openConnection 添加 port/timeout 校验;pmsg_recv(int) 添加UTF-8校验和文档警告;MsgClient 构造/call 添加参数校验 | -| [MsgServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/MsgServer.hpp) | **P0修复**: _close_all_clients() 改为持 send_lock 关闭并 shutdown;构造函数添加全量配置校验 | -| [AsyncQueue.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/AsyncQueue.hpp) | 添加 / 头文件;构造函数校验 handler 非空、batch_size、flush_interval、poll_timeout 范围 | -| [RpcClient.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcClient.hpp) | 构造函数校验 timeout、retry_interval 范围 | -| [RpcServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcServer.hpp) | add_route() 校验 name 非空、handler 非空 | +| 文件 | 变更内容 | +| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Msg.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/Msg.hpp) | 新增 isValidUtf8()/validateTimeout()/validatePort();recvFrameDeadline 添加参数校验和UTF-8校验;openConnection 添加 port/timeout 校验;pmsg_recv(int) 添加UTF-8校验和文档警告;MsgClient 构造/call 添加参数校验 | +| [MsgServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/MsgServer.hpp) | **P0修复**: \_close_all_clients() 改为持 send_lock 关闭并 shutdown;构造函数添加全量配置校验 | +| [AsyncQueue.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/AsyncQueue.hpp) | 添加 / 头文件;构造函数校验 handler 非空、batch_size、flush_interval、poll_timeout 范围 | +| [RpcClient.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcClient.hpp) | 构造函数校验 timeout、retry_interval 范围 | +| [RpcServer.hpp](file:///Users/qingsong/Documents/blackbird/libmsg/inc/us/RpcServer.hpp) | add_route() 校验 name 非空、handler 非空 | --- @@ -466,14 +160,14 @@ struct ConnectionLock { - **第一轮(2026-08-06)**: 3 个问题(P0 竞态、P1 Slowloris、P2 脱敏正则)已修复。 - **第二轮(2026-08-07)**: 5 个问题(P0 批量关闭竞态回归、4 个 P2 健壮性)已修复。 - **第三轮(2026-08-07)**: 3 个新问题(P1 服务端发送超时缺失、P2 reply 失败未清理、P3 RpcClient success 类型校验)已修复。 -- **第四轮(2026-08-07)**: 5 个新问题(P1 reply/broadcast 残余竞态、P2 _server_fd 数据竞争、P2 start 空回调校验、P3 _route 错误信息、P3 setsockopt 顺序)已修复。 +- **第四轮(2026-08-07)**: 5 个新问题(P1 reply/broadcast 残余竞态、P2 \_server_fd 数据竞争、P2 start 空回调校验、P3 \_route 错误信息、P3 setsockopt 顺序)已修复。 结合四轮审计结果,libmsg 核心通信层的安全状况良好: - **内存安全**: 无缓冲区溢出、无 use-after-free(fd 生命周期通过 ConnectionLock + shared_ptr + atomic active 严格管理) - **DoS 防护**: 64MiB 消息上限、deadline 模式接收超时(防Slowloris)、服务端发送超时 SO_SNDTIMEO(防慢客户端耗尽资源)、max_connections 连接数限制、max_queue_size 队列上限、poll_timeout 防忙轮询、全量参数边界校验 -- **竞态安全**: 所有 fd 关闭路径均持有 per-connection send_mtx 并设置 active=false,reply/broadcast 获取锁后检查 active,**彻底消除 CWE-362 竞态(含正向和反向场景)**;_server_fd 使用 atomic 消除数据竞争 UB -- **协议健壮性**: UTF-8 收发端校验与 Python 版对齐、RpcClient 响应字段类型校验统一抛 RpcError、RpcServer _route 类型校验 +- **竞态安全**: 所有 fd 关闭路径均持有 per-connection send_mtx 并设置 active=false,reply/broadcast 获取锁后检查 active,**彻底消除 CWE-362 竞态(含正向和反向场景)**;\_server_fd 使用 atomic 消除数据竞争 UB +- **协议健壮性**: UTF-8 收发端校验与 Python 版对齐、RpcClient 响应字段类型校验统一抛 RpcError、RpcServer \_route 类型校验 - **异常安全**: worker 线程异常隔离、AsyncQueue fail-safe、Handler 异常不崩溃服务、RPC 错误脱敏、reply/broadcast 失败连接统一清理、start 空回调校验 遗留的 L-1~L-12 项为架构层面的已知限制/设计决策,非代码缺陷,可根据实际部署需求决定是否增强。 diff --git a/libmsg/inc/us/AsyncQueue.hpp b/libmsg/inc/us/AsyncQueue.hpp index baad12f..31fdd4e 100644 --- a/libmsg/inc/us/AsyncQueue.hpp +++ b/libmsg/inc/us/AsyncQueue.hpp @@ -90,6 +90,8 @@ public: _stop(false), _wakeup(false), _dropped(0) { + // [libmsg-audit P2-3] 入口校验:handler 非空、batch_size>0、flush_interval∈(0,300]、 + // poll_timeout∈(0,5]。拒绝非法输入(如 batch_size=0 导致死循环、poll_timeout=0 忙轮询)。 if (!_handler) { throw std::invalid_argument("AsyncQueue: handler must not be null"); } diff --git a/libmsg/inc/us/Msg.hpp b/libmsg/inc/us/Msg.hpp index cf47ee1..04cd15f 100644 --- a/libmsg/inc/us/Msg.hpp +++ b/libmsg/inc/us/Msg.hpp @@ -51,9 +51,9 @@ namespace detail { using namespace msg_constants; -// ---- UTF-8 合法性校验(对齐 Python decode('utf-8'))------------------------ +// ---- UTF-8 合法性校验(对齐 Python decode('utf-8'))------------------------ [libmsg-audit P2-4] // 防御性校验:协议声明载荷为 UTF-8,非法字节序列表示协议错误或攻击, -// 直接抛 MsgConnectionException 断开连接。 +// 直接抛 MsgConnectionException 断开连接(拒绝 overlong / 代理对 / 超 U+10FFFF)。 inline bool isValidUtf8(const char* data, size_t len) noexcept { const unsigned char* bytes = reinterpret_cast(data); size_t i = 0; @@ -91,7 +91,9 @@ inline bool isValidUtf8(const char* data, size_t len) noexcept { return true; } -// ---- 参数校验辅助 -------------------------------------------------------- +// ---- 参数校验辅助 -------------------------------------------------------- [libmsg-audit P2-3] +// validateTimeout / validatePort:统一拒绝非法 timeout((0,300]s)与 port([1,65535]), +// 防止 0/负值导致 deadline 立即到期、极大值绕过 Slowloris 防护、port 越界 htons 截断。 inline void validateTimeout(double timeout_sec) { if (timeout_sec <= 0 || timeout_sec > 300.0) { // 最大 5 分钟 throw MsgException("invalid timeout: " + std::to_string(timeout_sec) + " seconds (must be in (0, 300])"); @@ -153,9 +155,10 @@ inline void recvExact(int sockfd, char *buf, size_t n) { } } -// ---- deadline 模式精确读取 n 字节(总超时,防 Slowloris)------------------- +// ---- deadline 模式精确读取 n 字节(总超时,防 Slowloris)------------------- [libmsg-audit P1-1] // 与 Python 版 _recv_exact 对齐:每次 select 前按剩余时间收紧超时, -// header+payload 共享同一个 deadline,总接收时间不超过 timeout_sec。 +// header+payload 共享同一个 deadline,总接收时间不超过 timeout_sec, +// 防止单连接以不完整帧头长期占用接收线程(Slowloris 变体,CWE-400)。 inline void recvExactDeadline(int sockfd, char *buf, size_t n, const std::chrono::steady_clock::time_point& deadline) { @@ -217,7 +220,7 @@ inline std::string recvFrameDeadline(int sockfd, double timeout_sec) { std::string payload(static_cast(length), '\0'); if (length > 0) { recvExactDeadline(sockfd, payload.data(), length, deadline); - // UTF-8 合法性校验(对齐 Python decode('utf-8')),非法载荷视为协议错误 + // [libmsg-audit P2-4] UTF-8 合法性校验(对齐 Python decode('utf-8')),非法载荷视为协议错误 if (!isValidUtf8(payload.data(), length)) { throw MsgConnectionException("invalid UTF-8 payload"); } @@ -246,6 +249,7 @@ inline void sendAll(int sockfd, const char *data, size_t len) { // ---- 短连接 helper:connect → action → close ------------------------------ inline int openConnection(const std::string& addr, int port, double timeout) { + // [libmsg-audit P2-3] 入口边界校验:port 与 timeout 必须在合法范围。 validatePort(port); validateTimeout(timeout); int fd = ::socket(AF_INET, SOCK_STREAM, 0); @@ -317,7 +321,7 @@ inline std::string pmsg_recv(int sockfd) { std::string payload(static_cast(length), '\0'); if (length > 0) { detail::recvExact(sockfd, payload.data(), length); - // UTF-8 合法性校验(对齐 Python decode('utf-8')),非法载荷视为协议错误 + // [libmsg-audit P2-4] UTF-8 合法性校验(对齐 Python decode('utf-8')),非法载荷视为协议错误 if (!detail::isValidUtf8(payload.data(), length)) { throw MsgConnectionException("invalid UTF-8 payload"); } diff --git a/libmsg/inc/us/MsgServer.hpp b/libmsg/inc/us/MsgServer.hpp index 9244d60..f725dd1 100644 --- a/libmsg/inc/us/MsgServer.hpp +++ b/libmsg/inc/us/MsgServer.hpp @@ -81,9 +81,14 @@ struct MsgServerConfig { /// 每连接的发送锁和活跃标志。 /// -/// active 标志用于防止 CWE-362 竞态:当 _remove_client() 先获取 send_mtx -/// 并 close(fd) 后,reply/broadcast 后获取 send_mtx 时通过 active==false -/// 得知 fd 已关闭,立即放弃发送,避免向 OS 复用的 fd 发送数据。 +/// [libmsg-audit P0-1 / P0-2 / P1-3] 组合 send_mtx(互斥锁)+ active(原子标志)彻底消除 +/// CWE-362 竞态: +/// - 第一轮 P0-1:单客户端移除路径先持 send_mtx 再 close,reply/broadcast 先获取 +/// send_mtx 则 _remove_client 等待,防止 fd 关闭后被 OS 复用导致数据发往错误客户端。 +/// - 第二轮 P0-2:批量关闭路径 _close_all_clients 同样持 send_mtx 后 shutdown+close, +/// 修复第一轮只覆盖单路径的竞态回归。 +/// - 第四轮 P1-3:active 标志堵住反向竞态——_remove_client 先获取锁并 close(fd) 后, +/// reply/broadcast 后获取锁时通过 active==false 得知 fd 已关闭,立即放弃发送。 struct ConnectionLock { std::mutex send_mtx; std::atomic active{true}; @@ -101,6 +106,10 @@ public: explicit MsgServer(const std::string& addr, int port, MsgServerConfig config = {}) : _addr(addr), _port(port), _config(config), _server_fd(-1), _running(false) { + // [libmsg-audit P2-3 / P2-5] 全量边界校验:拒绝非法 port / workers / recv_timeout / + // max_connections / max_queue_size / listen_backlog / poll_timeout。防止 htons 截断产生 + // 错误端口、负值导致所有连接被拒或队列无法提交、极大值(>300s)绕过 Slowloris 防护、 + // poll_timeout=0 导致 select 忙轮询 CPU 100%。 detail::validatePort(port); if (config.workers < 0 || config.workers > 256) { throw MsgException("invalid workers: " + std::to_string(config.workers) + " (must be in [0, 256])"); @@ -136,6 +145,8 @@ public: * 回调内可安全调用 reply()/broadcast()/stop()。 */ void start(MessageCallback on_message) { + // [libmsg-audit P2-8] 空回调会在 worker 线程 / RpcServer 分发中抛 std::bad_function_call, + // 提前校验并给出明确错误,避免行为不符合预期且错误信息含糊。 if (!on_message) { throw MsgException("MsgServer::start(): on_message callback must not be null"); } @@ -233,14 +244,14 @@ public: conn_lock = it->second; } std::lock_guard lock(conn_lock->send_mtx); - // 检查 active:防止 _remove_client 先获取 send_mtx 并 close(fd) 后, - // reply 向已关闭/被 OS 复用的 fd 发送数据(CWE-362) + // [libmsg-audit P1-3] 检查 active:防止 _remove_client 先获取 send_mtx 并 close(fd) 后, + // reply 向已关闭/被 OS 复用的 fd 发送数据(CWE-362 反向竞态)。 if (!conn_lock->active.load()) return; try { pmsg_send(sockfd, msg); } catch (const MsgException&) { - // 发送失败(连接断开或发送超时),标记为 failed 由主循环清理。 - // 与 broadcast() 保持一致,避免连接残留。 + // [libmsg-audit P2-6] 发送失败(连接断开或发送超时),标记为 failed 由主循环清理。 + // 与 broadcast() 保持一致,避免连接残留不清理(原 reply 静默忽略,连接滞留)。 std::lock_guard flk(_failed_mtx); _failed.insert(sockfd); } @@ -267,12 +278,12 @@ public: } for (auto& [fd, conn_lock] : targets) { std::lock_guard slock(conn_lock->send_mtx); - // 检查 active:防止 _remove_client 先 close(fd) 后向复用 fd 发送(CWE-362) + // [libmsg-audit P1-3] 检查 active:防止 _remove_client 先 close(fd) 后向复用 fd 发送(CWE-362 反向竞态) if (!conn_lock->active.load()) continue; try { pmsg_send(fd, msg); } catch (const MsgException&) { - // 发送失败,标记为 failed 由主循环清理 + // [libmsg-audit P2-6] 发送失败,标记为 failed 由主循环统一清理 std::lock_guard flk(_failed_mtx); _failed.insert(fd); } @@ -314,7 +325,8 @@ private: } if (!has_task) continue; - // workers > 0 模式下,消息接收在 worker 线程中完成(防事件循环阻塞 CWE-400) + // [libmsg-audit P1-1] workers > 0 模式下,消息接收在 worker 线程中完成(防事件循环阻塞 CWE-400), + // 避免攻击者以 max_connections 个半开连接阻塞事件循环长达数小时(Slowloris 变体)。 if (task.needs_recv) { try { task.message = pmsg_recv(task.fd, _config.recv_timeout); @@ -452,12 +464,13 @@ private: } if (client_fd >= FD_SETSIZE) { ::close(client_fd); return; } - // 禁用 Nagle 算法和设置发送超时(在加入 _clients 之前,确保连接配置完整) + // [libmsg-audit P3-3] 禁用 Nagle 算法和设置发送超时(在加入 _clients 之前,确保连接配置完整), + // 先完成所有 socket 配置再暴露给其他线程,避免半配置连接被回复。 int flag = 1; ::setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); - // 设置发送超时:防止慢客户端不读数据导致发送缓冲区填满, - // 使 reply()/broadcast() 的 send() 永久阻塞(拒绝服务)。 + // [libmsg-audit P1-2] 设置发送超时:防止慢客户端不读数据导致发送缓冲区填满, + // 使 reply()/broadcast() 的 send() 永久阻塞(拒绝服务,CWE-400)。 // 超时后 sendAll() 抛 MsgTimeoutException,被 reply/broadcast 捕获并标记 failed 清理。 struct timeval sndtv {}; constexpr double kServerSendTimeout = 5.0; // 秒 @@ -511,7 +524,7 @@ private: return; } - // 线程池模式:标记 busy,提交任务(由 worker 负责 recv + callback, + // [libmsg-audit P1-1] 线程池模式:标记 busy,提交任务(由 worker 负责 recv + callback, // 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400) { std::lock_guard blk(_busy_mtx); @@ -557,9 +570,9 @@ private: _send_locks.erase(it); } } - // 持有 send_mtx 后设置 active=false 再 close(fd): - // 1. 防止 broadcast/reply 在 fd 关闭后向 OS 复用的新连接发送数据(CWE-362) - // 2. active=false 让 reply/broadcast 在获取锁后得知 fd 已关闭,放弃发送 + // [libmsg-audit P0-1 / P1-3] 持有 send_mtx 后设置 active=false 再 close(fd): + // 1. 防止 broadcast/reply 在 fd 关闭后向 OS 复用的新连接发送数据(CWE-362,第一轮 P0-1) + // 2. active=false 让 reply/broadcast 在获取锁后得知 fd 已关闭,放弃发送(第四轮 P1-3 反向竞态) if (conn_lock) { std::lock_guard slk(conn_lock->send_mtx); conn_lock->active.store(false); @@ -587,8 +600,9 @@ private: _clients.clear(); _send_locks.clear(); } - // 持有每个连接的 send_mtx 后设置 active=false 再 close(fd): - // 防止 stop()/析构时 reply/broadcast 正在发送,导致数据发到 OS 复用的新连接(CWE-362) + // [libmsg-audit P0-2 / P1-3] 持有每个连接的 send_mtx 后设置 active=false 再 close(fd): + // 防止 stop()/析构时 reply/broadcast 正在发送,导致数据发到 OS 复用的新连接(CWE-362)。 + // 第二轮 P0-2 修复批量关闭路径的竞态回归,第四轮 P1-3 的 active 标志堵住反向竞态。 for (auto& [fd, conn_lock] : to_close) { if (conn_lock) { std::lock_guard slk(conn_lock->send_mtx); @@ -611,7 +625,7 @@ private: std::string _addr; int _port; MsgServerConfig _config; - std::atomic _server_fd; ///< 监听 socket fd(atomic 防止 stop() 与 _event_loop 数据竞争) + std::atomic _server_fd; ///< [libmsg-audit P2-7] 监听 socket fd(atomic 防止 stop() 与 _event_loop 数据竞争 UB,CWE-362/662) std::atomic _running; MessageCallback _on_message; diff --git a/libmsg/inc/us/RpcClient.hpp b/libmsg/inc/us/RpcClient.hpp index dc9d035..c4e3589 100644 --- a/libmsg/inc/us/RpcClient.hpp +++ b/libmsg/inc/us/RpcClient.hpp @@ -75,6 +75,7 @@ public: _timeout(timeout), _retry_on_refused(retry_on_refused), _retry_interval(retry_interval) { + // [libmsg-audit P2-3] 入口边界校验:timeout ∈ (0,300]s,retry_interval ∈ (0,60]s。 if (timeout <= 0 || timeout > 300.0) { throw MsgException("invalid timeout: " + std::to_string(timeout)); } @@ -128,7 +129,8 @@ public: } const json::value& success = obj.at("success"); if (!success.is_bool()) { - // 类型不符时不要调用 as_bool()(会抛未捕获的 boost system_error),统一抛 RpcError + // [libmsg-audit P3-1] 类型不符时不要调用 as_bool()(会抛未捕获的 boost system_error), + // 统一抛 RpcError,与其余非法响应处理保持一致,避免底层异常泄露内部细节。 throw RpcError("非法 RPC 响应(success 字段不是 bool)"); } if (!success.as_bool()) { diff --git a/libmsg/inc/us/RpcServer.hpp b/libmsg/inc/us/RpcServer.hpp index 65bac92..5a7d20d 100644 --- a/libmsg/inc/us/RpcServer.hpp +++ b/libmsg/inc/us/RpcServer.hpp @@ -83,6 +83,7 @@ public: * @param name 路由名(对应请求中的 _route 字段),不能为空 * @param handler 处理函数,签名 json::value(const json::object& req) */ + // [libmsg-audit P2-3] 入口校验:name 与 handler 均非空,否则后续调用会崩溃。 void add_route(const std::string& name, Handler handler) { if (name.empty()) { throw MsgException("route name cannot be empty"); @@ -127,7 +128,7 @@ private: json::object req = req_val.as_object(); - // 提取 _route + // [libmsg-audit P3-2] 提取 _route:区分"字段缺失"与"类型不符"返回不同错误信息,避免误导客户端。 std::string route; if (auto it = req.find("_route"); it != req.end() && it->value().is_string()) { route = it->value().as_string().c_str(); @@ -181,9 +182,10 @@ private: _server.reply(fd, json::serialize(resp)); } - // ---- 错误信息脱敏 ------------------------------------------------------ + // ---- 错误信息脱敏 ------------------------------------------------------ [libmsg-audit P2-1] // 匹配绝对路径(如 /opt/micro_servers/db/xxx.py 或 /usr/lib/...),替换为 // 不返回函数堆栈、SQL、文件路径等内部细节给客户端;完整异常由 on_error 钩子记录到服务端日志。 + // 正则已扩展为 /\S+(?:\/\S*)*,覆盖含空格/括号/@/~ 等特殊字符的路径(原正则会泄露此类路径,CWE-209)。 static std::string _sanitize_error(const std::string& msg) { if (msg.empty()) return msg; From c07ad7ac39c3cb7f562fe0a2b30c30f510f47499 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 01:27:48 +0800 Subject: [PATCH 010/196] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E6=9E=B6?= =?UTF-8?q?=E6=9E=84/=E6=A8=A1=E5=9D=97=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=AF=84=E4=BC=B0=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/ARCHITECTURE.md: 系统架构说明 - docs/MODULES.md: 模块清单与职责 - docs/blackbird_project_profile.md: 项目画像 - docs/blackbird_project_evaluation.md: 项目评估 - docs/reports/fsys_public_frontend_research_20260810.md: fsys 公开前端调研 --- docs/ARCHITECTURE.md | 252 ++++++++++++ docs/MODULES.md | 89 +++++ docs/blackbird_project_evaluation.md | 37 ++ docs/blackbird_project_profile.md | 372 ++++++++++++++++++ .../fsys_public_frontend_research_20260810.md | 141 +++++++ 5 files changed, 891 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/MODULES.md create mode 100644 docs/blackbird_project_evaluation.md create mode 100644 docs/blackbird_project_profile.md create mode 100644 docs/reports/fsys_public_frontend_research_20260810.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..12df0af --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,252 @@ +# Blackbird 架构文档(UniServe Framework) + +> 权威架构参考。本文以 `core/init.txt`(实际运行清单)与 `git HEAD` 为事实来源。 +> 与根目录 `README.md` 的偏差见文末「§10 与 README 的偏差」。 +> 最后核对于:2026-08-10。 + +--- + +## 1. 文档目的与范围 + +Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe Framework),用于支撑一个带内容审核的社区/笔记类 Web 应用及其周边 AI/嵌入式子系统。本文描述: + +- 系统上下文与边界 +- 部署拓扑(容器级) +- 关键请求链路 +- 服务清单(含语言、端口、职责、当前状态) +- 共享基础库与横切关注点 +- 关键架构决策(ADR 摘要) +- 演进策略与已知漂移 + +**不在本文范围**:单模块内部算法、前端页面细节、硬件机器人/帧缓冲子系统的实现。 + +--- + +## 2. 系统上下文(System Context) + +``` + 浏览器 / 客户端 + │ HTTPS :443 + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Blackbird 系统边界 │ + │ waf → ca → gateway → {fsys, dnfs2, core} │ + │ 内部:db2 / ai_review / smtp / captcha2 / core(confs) │ + │ 独立:aibox(+BotOps) / bot4cj / lci │ + └─────────────────────────────────────────────────────────────┘ + │ pymsg/TCP (内部 RPC) │ SMTP (外部邮件) + ▼ ▼ + db2 / ai_review / smtp 邮件服务商 (注册验证码) +``` + +**参与者**: +- 终端用户:经 `:443` 访问社区首页、登录/注册、树状笔记。 +- 运维/开发者:经 `core:5200` 管理接口启停子进程。 +- 外部依赖:SMTP 邮件服务商(注册验证码)、DeepSeek API(ai_review)。 + +--- + +## 3. 部署拓扑(Container Diagram) + +``` + ┌──────────────────────────────────────────────────┐ + │ core (PMC / Nidus) │ + │ 进程托管 :5200 (HTTP 管理) + 内嵌 confs :8010 │ + │ 读 init.txt 拉起并守护下列子进程 │ + └───────────────────────┬──────────────────────────┘ + │ 启动 / 守护 + ┌────────────────┬───────────────────────┼──────────────────┬─────────────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ redirect80 │ │ waf │ │ ca │ │ gateway │ │ fsys │ + │ :80 │ │ :443 │ │ :8443 │ │ :8445 │ │ :9200 │ + │ Python │ │ Go │ │ C++20 │ │ C++20 │ │ C++20 │ + └─────┬──────┘ └─────┬──────┘ └─────▲─────┘ └─────▲─────┘ └─────▲─────┘ + │ 301/308 │ TLS+coraza │ auth+forward │ loopback │ proxy + └───────────────►│ │ │ pure-fwd │ notes + └────────────────────►│ │ └────┬────┘ + │ │ │ pymsg + ┌────────┴────────┐ ┌─────┴────────┐ ┌────────▼─────┐ + │ dnfs2 :8006 │ │ core :5200 │ │ dnfs2 :8006 │ + │ C++20 │ │ (管理/守护) │ │ (经 fsys 代理)│ + └───────▲────────┘ └──────────────┘ └──────────────┘ + │ pymsg + ┌────────────┼─────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌───────────┐ ┌───────────┐ + │ db2 :8005 │ │ai_review │ │ smtp │ + │ C++20 pymsg│ │ :8007 Py │ │ :8008 Py │ + │ +Logs 表 │ │ RAG+LLM │ │ pymsg │ + └─────▲──────┘ └─────▲─────┘ └─────▲─────┘ + │ │ 审核 │ 发码 + │ └─────────────┘ + ┌─────┴──────────────────────────────┐ + │ captcha2 :8009 (C++20, pymsg) │ + └─────────────────────────────────────┘ + + 独立 / 外设(不在主链路): + aibox :8765 (Python WebSocket, 独立产品) + BotOps (aibox 的 TUI 前端) + bot4cj (Cangjie + 树莓派 I2C, 硬件) + lci (C++ /dev/fb0, 嵌入式 GUI) + actpred / memdb / libtry (实验 / 支撑库) + + 日志链路: 各服务 log_client ──pymsg──► db2:8005 Logs 表 (fail-safe, db 不可用时静默丢弃) + 配置链路: core 内嵌 confs :8010 ──pymsg get──► 各服务 (confs/configs/*.ini, mtime 自动重载) +``` + +> 注:`core` 启动参数为 `./nidus.out -i init.txt --serve 5200 --confs 8010`,`--confs` 启用内嵌配置服务(由 confs2 合并而来,与 Python confs 协议兼容)。 + +--- + +## 4. 关键请求链路 + +### 4.1 正常访问(已登录) +1. 客户端若以 HTTP 访问 `:80`,`redirect80` 重定向到 HTTPS `:443`(GET/HEAD→301,其余→308 保留 body)。 +2. 请求打到 `waf:443`(Go/coraza):终止 TLS,运行 SecLang 规则;命中→403。 +3. 未命中规则 → 明文 HTTP 转发到 `ca:8443`,注入 `X-Forwarded-For` / `X-Forwarded-Proto`。 +4. `ca` 验签 Token,注入 `X-Auth-User` / `X-Auth-Admin`,转发到 `gateway:8445`(仅 loopback)。 +5. `gateway` 检查:显式 `Target-Url` 必须带 `X-Auth-User`(否则 401),且必须在 `Proxy.opened` 白名单(否则 403);转发到 `fsys:9200` / `dnfs2:8006` / `core:5200`。 +6. `fsys` 处理前端资源,笔记类请求代理到 `dnfs2:8006`。 + +### 4.2 注册 / 登录(ca 负责) +- `/login`、`/register`、`/reset_*`、`/captcha/image`、`/whoami` 由 `ca:8443` 直接处理(不再经 gateway 鉴权层)。 +- 密码:PBKDF2-HMAC-SHA256(10 万轮 + 16B 随机盐),常数时间比较。 +- Token:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1h 过期;RSA 密钥内存生成,重启即全失效;jti 黑名单 + 用户版本号双轨吊销。 +- 注册邮件验证码:ca 经 pymsg/TCP 调 `smtp:8008` 发送与校验;登录强制 CAPTCHA(`captcha2:8009`)。 +- 用户数据:ca 经 pymsg/TCP 调 `db2:8005` 存取(`Users` 表,首位注册者即管理员)。 +- 速率限制:登录 5/5min、注册 3/10min、发码 IP+邮箱双窗口,按 `X-Forwarded-For` 计数。 + +### 4.3 笔记写入(含内容审核) +`fsys → dnfs2:8006` → 新增节点前 `dnfs2` 调 `ai_review:8007 /review`(fail-closed,异常一律拦截)→ 审核通过后经 pymsg 调 `db2:8005 execute` 持久化。 + +### 4.4 日志 / 配置(横切) +- 各服务内置 `log_client`(异步队列 + 后台批量 flush)→ pymsg 写 `db2:8005` 的 `Logs` 表;`db2` 自带 30 天/100 万条清理线程。 +- 各服务启动时经 pymsg `get` 从 `core:8010` 拉取自身 `.ini`;`confs/configs/` 下 mtime 变化自动重载;环境变量覆盖;命令行参数最高优先级。 + +--- + +## 5. 服务清单(当前事实状态) + +| 服务 | 语言 | 端口 | 绑定 | 职责 | 状态 | +|------|------|------|------|------|------| +| **core** | C++20 | 5200 / 8010 | 5200 管理 / 8010 loopback | 进程托管(PMC) + 内嵌 confs 配置服务 | 生产 | +| **redirect80** | Python | 80 | 0.0.0.0 | HTTP→HTTPS 重定向 | 生产 | +| **waf** | Go | 443 | 0.0.0.0 | 边缘网关:TLS 终止 + coraza WAF | 生产 | +| **ca** | C++20 | 8443 | 127.0.0.1 | 中央认证 + 身份头注入 + 前置转发 | 生产 (v1.0.0) | +| **gateway** | C++20 | 8445 | 127.0.0.1 | 纯转发反向代理(loopback 强制自检) | 生产 (v0.9.0) | +| **fsys** | C++20 | 9200 | 127.0.0.1 | 前端托管 + dnfs 代理(static 的 C++ 重写) | 生产 | +| **dnfs2** | C++20 | 8006 | 127.0.0.1 | 树状笔记 CRUD | 生产(dnfs 的 C++ 重写) | +| **db2** | C++20 | 8005 | 127.0.0.1 | 通用 DB(pymsg execute) + 日志汇聚 | 生产(db 的 C++ 重写) | +| **ai_review** | Python | 8007 | 127.0.0.1 | AI 内容审核(DeepSeek + ChromaDB RAG) | 生产 | +| **smtp** | Python | 8008 | 127.0.0.1 | 邮件验证码后端(pymsg) | 生产(smtp3 C++ 重写待上线) | +| **captcha2** | C++20 | 8009 | 127.0.0.1 | 图形验证码(pymsg) | 生产(captcha 的 C++ 重写) | +| **aibox** | Python | 8765 | 127.0.0.1 | 独立 AI 智能体(WebSocket) | 独立产品 | +| **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 | +| **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 | +| **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 | +| **actpred** | Python | — | — | ML 预训练/推理 | 实验 | +| **memdb** | C++20 | — | — | 内存数据库 | 实验/支撑 | +| **libtry** | C | — | — | 嵌入式脚本解释器 | 支撑 | + +### 已被替代、不应再部署的遗留模块(不在 init.txt) +| 遗留 | 替代 | 说明 | +|------|------|------| +| `static` (Python) | `fsys` | 前端托管已 C++ 重写 | +| `db` (Python) | `db2` | DB 服务已 C++ 重写 | +| `dnfs` (Python) | `dnfs2` | 笔记服务已 C++ 重写 | +| `captcha` (Python) | `captcha2` | 验证码已 C++ 重写 | +| `confs` (Python) | 并入 `core` | 配置服务内嵌进 core(ConfsTask) | +| `smtp3` (C++) | (预备) | 代码就绪,init.txt 仍用 Python `smtp`,尚未平替 | + +--- + +## 6. 共享基础库 + +| 库 | 语言 | 说明 | +|----|------|------| +| libth | C++20 | 线程 / 条件变量 / FSM / 任务接口 | +| libhttp | C++20 | HTTP/WebSocket(Boost.Beast,服务端 + 客户端) | +| libcrypt | C++20 | AES_CBC / RSA / SHA256 / Base64 | +| libsqlite3 | C++20 | SQLite3 header-only 封装 | +| libmsg | C++20 | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 JSON),与 Python pymsg 字节级兼容,提供 RpcClient/RpcServer/AsyncQueue | +| libtry | C | 嵌入式脚本解释器(递归下降,中文标识符) | +| pymsg | Python | 自研 RPC 基础库(`[4B 大端长度][UTF-8 JSON]`),Python 侧 db/log_client 依赖 | + +**协议约定**:内部 RPC 统一用 pymsg(TCP 长度前缀帧 + JSON);对外 HTTP;ca↔gateway 之间为明文 HTTP(TLS 已由 waf 终止)。 + +--- + +## 7. 横切关注点 + +### 7.1 安全分层(纵深防御) +- **边缘**:`waf` 终止 TLS + coraza 规则(本地自定义 100000–199999 / OWASP CRS 900000–999999),命中即 403。 +- **应用层**:`ca` 负责鉴权(PBKDF2 + RSA Token + CAPTCHA + 速率限制);`gateway` 仅 loopback,身份完全信任 ca 注入头。 +- **网络隔离**:`db2/ai_review/smtp/captcha2/confs` 仅监听 loopback,不经 gateway 直访(`Proxy.closed`)。 +- **fail-closed 文化**:`ai_review` 异常即拦截;`dnfs2` Token 验签失败返 503;`gateway` 错误响应脱敏;SMTP 内部错误对外统一通用码(如 3103)。 + +### 7.2 可观测性 +- 集中式日志:所有服务经 `log_client` 异步写 `db2` 的 `Logs` 表(service/level/message/trace_id/extra)。 +- **缺口**:无分布式追踪(trace_id 仅记录未串联)、无指标/监控中间件、无告警。 + +### 7.3 配置管理 +- `core` 内嵌 confs(`:8010`)统一分发 `confs/configs/*.ini`;内存缓存 + mtime 重载;路径遍历防护。 +- 三层覆盖:confs 默认值 → 环境变量 → 命令行参数。 + +### 7.4 弹性 / 容错 +- 启动顺序敏感:`core` 拉起顺序为 db2→ai_review→dnfs2→smtp→fsys→waf→ca→gateway→captcha2→aibox→redirect80;pymsg 客户端带 `retry_on_refused` 等依赖就绪。 +- 日志 fail-safe:db2 不可用时静默丢弃,不影响业务。 +- 有状态单点:`smtp` / `captcha2` 验证码存进程内存,**不支持多实例水平扩展**(需换 Redis 才能扩)。 + +--- + +## 8. 关键架构决策(ADR 摘要) + +**ADR-1|认证从 gateway 剥离为独立 ca 模块(v0.9.0 / ca v1.0.0)** +- 背景:gateway 同时承担鉴权与转发,职责过重、RSA 密钥与业务逻辑耦合。 +- 决策:ca 独立负责 Token 签发/验签、登录/注册/限流/CAPTCHA,gateway 退居 `127.0.0.1:8445` 纯转发。 +- 后果:+1 网络跳数;ca 成为安全边界(loopback + strip-then-set);gateway 不再依赖 libcrypt/OpenSSL。 + +**ADR-2|核心服务 C++20 重写(db→db2, dnfs→dnfs2, static→fsys, captcha→captcha2, confs→core;smtp3 就绪)** +- 背景:Python 服务依赖 venv/pip/Flask,且部分存在 P0/P1 安全问题。 +- 决策:用 libhttp+libmsg+boost 重写,接口字节级兼容(pymsg/HTTP 路由不变),可平替。 +- 后果:去掉 Python 运行时依赖、修复安全缺陷、提升吞吐;过渡期需双版本并存维护;ai_review/aibox/smtp/redirect80 仍保留 Python。 + +**ADR-3|confs 配置服务内嵌进 core** +- 背景:独立 confs 进程增加编排复杂度。 +- 决策:confs2 合并为 `core` 的 `ConfsTask`,由 `--confs ` 启用。 +- 后果:少一个守护进程;core 略微变重;配置协议与目录结构不变。 + +**ADR-4|pymsg 作为内部 RPC 标准** +- 决策:内部数据访问统一 `[4B 大端长度][UTF-8 JSON]` over TCP,C++/Python 双实现字节级兼容。 +- 后果:业务服务不直接连数据库,数据访问层解耦;引入轻量自研协议(无现成生态)。 + +--- + +## 9. 演进策略与已知缺口 + +- **无服务注册发现 / 容器编排**:服务地址在 `waf/config.yaml` 与 `gateway` 的 `Proxy.*` 中静态配置,由 `core` 进程托管。适合个人/中小规模 DIY,但扩展需手工。 +- **smtp3 待上线**:C++ 重写已完成并通过安全审计,但 init.txt 仍指向 Python `smtp`,建议择机平替以消除最后一块 Python 有状态服务。 +- **可观测性缺口**:缺 tracing/metrics/告警,建议后续引入轻量方案(或复用 db2 Logs + 定时聚合)。 +- **水平扩展限制**:smtp/captcha 内存态为单点,若需多实例需引入 Redis 等共享存储。 + +--- + +## 10. 与 README.md 的偏差(待同步) + +以下为本文相对于根 `README.md` 的纠正点,建议择机回填 README: + +1. **端口**:README 端口表写 `gateway 8443`,实际 `ca:8443`、`gateway:8445`(v0.9.0 起)。README 正文已正确,仅端口表需改。 +2. **前端模块**:README 仍列 `static`(Python)为主,实际生产为 `fsys`(C++ 重写),`static` 已废弃。 +3. **C++ 重写版未进主表**:README 模块表仍以 `db`/`dnfs`/`captcha`/`confs` 为主,实际生产跑 `db2`/`dnfs2`/`captcha2`,`confs` 已并入 `core`。 +4. **init.txt 不含 confs**:独立 confs 服务已不用,配置由 `core --confs 8010` 内嵌提供。 +5. **fsys README 图示滞后**:`fsys/README.md` 架构图仍写 `gateway :8443`,应为 `ca:8443 → gateway:8445`。 +6. **工作记忆偏差**:项目记忆中的 `gateway:8443 → fsys / dnfs` 与 `confs:8010` 需更新为 `ca:8443 → gateway:8445`、`dnfs2`、confs 内嵌 core。 + +--- + +## 11. 文档管理约定(遵循既有规则) + +- 全局架构文档置于 `docs/`(本文件)。 +- 模块细节写在各模块目录 `README.md`。 +- 审计/审查/报告类文档分别入 `docs/security`、`docs/reviews`、`docs/reports`。 diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..1ff1952 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,89 @@ +# 模块清单(blackbird / micro_servers) + +> 一套 DIY 的轻量级「进程托管式微服务」(UniServe Framework)。 +> 混合语言:C++20(core/gateway/dnfs2/db2/lib*)、Python(fsys/dnfs/db/ai_review/smtp/captcha/confs/redirect80/BotOps/aibox/actpred/pymsg)、Go(waf)、仓颉/Cangjie(bot4cj)。 +> 由 `core` 读 `init.txt` 拉起并守护各子进程;`waf:443 → gateway:8443 → 业务服务` 为对外主链路。 + +## 一、核心与编排 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| core | C++20 | 5200 | 进程托管服务 PMC / Nidus,读 `init.txt` 拉起守护子进程,提供 HTTP 管理接口(`--send/--exec/--list/--kill/--serve`) | + +## 二、入口与网关层 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| redirect80 | Python | 80 | HTTP→HTTPS 重定向(GET/HEAD 301,其余 308 保留 body);纯标准库 `http.server` | +| waf | Go | 443 | 唯一对外入口,终止 TLS + coraza 规则匹配(SecLang)+ 反代到 gateway;`kill -HUP` 热加载;规则库 `waf/rules/` | +| gateway | C++20 | 8443 | 应用层网关(内部 HTTP),注册/登录鉴权、PBKDF2 密码哈希、RSA Token 签发校验、Cookie 认证、速率限制、反向代理(`Proxy.opened` 白名单) | + +## 三、业务服务(对外,经 gateway 代理) + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| fsys | Python/Flask | 9200 | 前端托管(社区首页/静态页面/媒体流/文件浏览);**原为 static,2026-08 重命名为 fsys**(core/init.txt 已同步),过渡期仍保留 legacy `static/` 目录存放 `fsys.py` | +| dnfs | Python/Flask | 8006 | 树状菜单(Dendrachy Network File-System),基于 pid 的节点树 CRUD,新增节点前调 ai_review 审核 | +| dnfs2 | C++20 | 8006 | dnfs 的 C++20 重实现(libhttp + libmsg),接口兼容可平替,修复 P0/P1(fail-closed 验签/所有权校验/脱敏) | +| ai_review | Python/Flask | 8007 | AI 内容审核,DeepSeek + ChromaDB RAG(色情/违禁药/赌博/诈骗/仇恨暴力),fail-closed 宁可误拦 | + +## 四、基础设施 / 支撑服务(仅内部调用) + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| db | Python/pymsg | 8005 | 通用数据库服务,SQL-over-TCP(SQLAlchemy Core 参数化防注入),内置集中式日志(Logs 表 + 自动清理) | +| db2 | C++20 | 8005 | db 的 C++20 重实现(libmsg + boost::json + libsqlite3),`execute` 路由/where 语法/响应格式对齐,可平替 | +| confs | Python/pymsg | 8010 | 统一配置分发,集中存放各服务配置(`confs/configs/`),内存缓存 + mtime 自动重载,路径遍历防护 | +| smtp | Python/pymsg | 8008 | 邮件服务,注册邮箱验证码后端(路由 send_email/verify_email/health),6 位码内存存储、冷却/限流/熔断 | +| captcha | Python/pymsg | 8009 | 验证码服务,图形验证码(一次性、5 分钟过期),经 gateway `GET /captcha/image` 代理给浏览器 | + +## 五、AI 智能体与前端 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| aibox | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端 | +| BotOps | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 | +| actpred | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 | + +## 六、机器人硬件与嵌入式 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 | +| lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 | +| 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 | + +## 七、共享基础库 + +| 模块 | 语言 | 说明 | +|------|------|------| +| libth | C++ | 线程库:`Thread`、`Cv_wait`、`Fsm`、`ITask` | +| libhttp | C++ | HTTP/WebSocket 库(Boost.Beast):`HttpServer`(含 WS 服务/代理)、`HttpClient`(含 WS 客户端) | +| libcrypt | C++ | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` | +| libsqlite3 | C++ | SQLite3 header-only 封装 | +| libmsg | C++ | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 载荷),`MsgClient/Server`、`RpcClient/Server`、`AsyncQueue`,与 Python pymsg 字节级兼容 | +| libtry | C | 嵌入式脚本解释器(~741 行),手写递归下降,支持中文标识符,用于嵌入式操作界面 shell | +| pymsg | Python | 自研 RPC 基础库(`[4B 长度][UTF-8 JSON]`),db 服务端与各 `log_client.py` 的基础依赖(setuptools 包) | +| memdb | C++ | 内存数据库(include/src),内部组件,未在主 README 模块表列出 | + +## 八、测试与文档 + +| 模块 | 说明 | +|------|------| +| tests | 黑盒测试套件(`test_db_*`/`test_gateway_*`/`test_waf_*`/`test_dnfs_*`),子进程 + HTTP 验证,前后自动备份恢复 db | +| docs | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析、INDEX.md),41+ 篇 md | + +## 九、根级构建与部署脚本 + +- `_inst.sh`:一键按依赖顺序构建安装(库先行,core 最后) +- `_setup.deb.sh` / `_setup.rhel.sh`:系统依赖安装(Debian/Ubuntu、RHEL 系) +- `docker.sh` / `docker-compose.yml`:容器化部署 +- 部署路径约定 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务 + +## 关键链路速记 + +- **请求主链路**:client → `redirect80`(:80→443) → `waf`(:443 TLS + WAF) → `gateway`(:8443 鉴权) → `fsys`(:9200) / `dnfs`(:8006) +- **日志链路**:各服务 `log_client.py`(异步批量)→ `db:8005` Logs 表(fail-safe,db 不可用时静默丢弃) +- **配置链路**:业务服务 → `confs:8010`(内存缓存 + mtime 重载,confs 不可用时回退本地配置) +- **数据/审核/邮件**:`dnfs` → `db:8005`(pymsg 持久化);`dnfs` → `ai_review:8007`(审核);注册邮件 `gateway` → `smtp:8008` +- **端口一览**:80 redirect80 · 443 waf · 8443 gateway · 5200 core · 9200 fsys · 8006 dnfs/dnfs2 · 8005 db/db2 · 8007 ai_review · 8009 captcha · 8008 smtp · 8010 confs · 8765 aibox diff --git a/docs/blackbird_project_evaluation.md b/docs/blackbird_project_evaluation.md new file mode 100644 index 0000000..9655d44 --- /dev/null +++ b/docs/blackbird_project_evaluation.md @@ -0,0 +1,37 @@ +# Blackbird / micro_servers 项目红队评估 + +> 评估对象:`docs/blackbird_project_profile.md`(2026-08-10 advisory 整合稿) +> 评估视角:材料红队(事实 / 推断 / 未知 + 矛盾与缺证) +> 评估时间:2026-08-10 + +## 一句话结论 +架构主张清晰、文档可审计性极高的自托管轻量微服务框架;但"安全评级 A-"缺乏代码级实证,3 项高危整改全部处于 `human_review_pending`,当前不适合暴露于多租户或不可信内网。 + +## 优势(已确认事实) +- **定位成立**:砍掉 K8s 的服务注册/编排/消息总线,保留微服务拆分与统一入口收益,自托管 + MIT,价值主张自洽。 +- **可审计性远超同类个人项目**:41+ 篇安全/分析文档 + 24 篇逐模块审计报告,风险闭环有基底。 +- **协议基石扎实**:pymsg/libmsg 字节级兼容的 `[4B 长度][UTF-8 JSON]` 帧,支撑 v1/v2 双栈平滑迁移。 +- **认证设计不弱**:PBKDF2 10 万轮、常数时间比较、RSA 签名 Token、Cookie 安全属性、fail-closed 边界。 + +## 关键风险(矛盾与缺证) +1. **评级名实不符**:称"B+→A-",但 3 高危(WAF 失效 / core root RCE / 内部零信任缺失)均未核实修复。A- 更像目标而非现状。 +2. **WAF 实际不拦截**:定位"唯一对外入口 + coraza",但 `waf/rules/local/custom.conf` 的 6 条 SecRule 全被注释、无 `crs/` 目录 → 实际仅 TLS 终止 + 响应头,SQLi/XSS/命令注入零防护。 +3. **core 本地 root RCE**:`parse_data_from_mq` 对 list/kill/exec 未鉴权即经 `/bin/sh -c` 执行,且 core 以 root 运行。这是致命项却被标 `human_review_pending`。 +4. **内部零信任缺失**:pymsg 无握手/无鉴权/明文无 TLS,db `execute` 接受任意 action/table/where,完全依赖 127.0.0.1 隔离。 +5. **端口口径漂移**:MODULES.md 记 gateway:8443,README 记 8445,未同步。 +6. **密钥变量静默失效**:`.env.example` 写 `OPENAI_API_KEY`,代码读 `DEEPSEEK_API_KEY`。 +7. **质量债**:v1/v2 双实现(db/db2、dnfs/dnfs2、pymsg/libmsg…)并存,无单元测试、无 CI(无 `.github/workflows`),回归与语义漂移风险高。 +8. **部署半成品**:docker-compose 仅纳 3 服务,生产以 systemd 为准——容器化链路不完整。 + +## 缺证项(需人工复核) +- 3 高危是否在 2026-08-08 之后的提交中已修复(研究报告基于快照,文档未给代码证据)。 +- docker-compose 补全状态、MODULES.md 端口同步状态。 + +## 优先级建议(收口路径) +- P0:启用 WAF 规则 + 补 `crs/`(直击高危 1)。 +- P0:core MQ 落地 `verify_admin_token` 并降权运行(直击高危 2)。 +- P1:内部服务加调用方鉴权 / pymsg 上 mTLS 或 Unix socket(直击高危 3)。 +- P2:ai_review `/review` 加鉴权 + 结构化提示词;统一 `DEEPSEEK_API_KEY`;引入单元测试与 CI;双实现收口单轨。 + +## 成熟度判定 +**个人/小团队可信内网自用 = 可用;暴露公网或多租户 = 不建议。** 文档完备度是亮点,代码整改完成度与测试覆盖是短板。 diff --git a/docs/blackbird_project_profile.md b/docs/blackbird_project_profile.md new file mode 100644 index 0000000..76453b4 --- /dev/null +++ b/docs/blackbird_project_profile.md @@ -0,0 +1,372 @@ +# Blackbird / micro_servers 项目资料长文档 + +> 文档类型:项目资料 / 综合参考手册(面向开发者、维护者、审计者与潜在贡献者) +> 资料来源:仓库主 `README.md`、各模块 `README.md`、`docs/INDEX.md`、`docs/MODULES.md`、`docs/security/*`、`docs/reviews/*`、`docs/reports/blackbird_research_report.md`、`docs/security/SECURITY_HARDENING_PLAN_20260803.md`、`docs/blackbird_promo_pack.md` +> 整理日期:2026-08-10 +> 文档状态:`advisory`(基于仓库现有文档整合;涉及最新代码整改状态的部分标注为 `human_review_pending`,需结合代码人工复核) + +--- + +## 0. 文档说明 + +本文档将 Blackbird(仓库名 `micro_servers`,框架名 **UniServe Framework**)过去分散在 40+ 篇说明、审计与报告中的信息,整合为一份可按章节查阅的项目资料。 + +- **事实边界**:所有模块职责、端口、协议描述以主 `README.md`(2026-08-10 口径,已含 `ca` 中央认证模块)为准;安全发现以 `blackbird_research_report.md`(2026-08-08 快照)与 `docs/security/` 逐模块审计为准。 +- **口径差异提醒**:`docs/MODULES.md` 仍记 `gateway:8443`,属 `ca` 认证前置化之前的旧口径;当前拓扑为 `waf:443 → ca:8443 → gateway:8445`,以主 `README.md` 为准。 +- **质量状态约定**:基于文档推断与整合的结论标记为 `advisory`;涉及"代码是否已修复某高危项"的判断标记为 `human_review_pending`,因其需对照最新提交逐文件核验。 + +--- + +## 1. 项目概述 + +### 1.1 一句话定位 + +Blackbird 是一套 **DIY 的轻量级"进程托管式微服务"框架**,用 C++20 / Python / Go / 仓颉(Cangjie) 混合实现,让个人与中小团队用一台服务器、一个脚本,拥有「安全入口 + 统一鉴权 + 数据库 + AI 审核 + 业务功能」的完整能力,免去 Kubernetes 等重型基础设施的复杂度。 + +### 1.2 设计哲学与核心主张 + +- **能力拆分但重量极轻**:保留微服务"拆分、统一入口、安全分层、可观测"的真正收益,砍掉服务注册发现、容器编排、消息总线等重型组件。 +- **你完全掌控**:数据自托管、代码 MIT 开源,不绑定任何云厂商。 +- **多语言自由组合**:核心用 C++(性能/系统),胶水与业务用 Python(效率),边缘网关用 Go(coraza 生态),硬件控制用仓颉(树莓派)。 +- **进程托管式编排**:一个 `core` 进程当"保姆",读 `init.txt` 拉起并守护所有子服务。 + +### 1.3 适用对象 + +| 群体 | 诉求 | Blackbird 的契合点 | +|------|------|-------------------| +| 独立开发者 / 个人站长 | 不想被运维压垮 | 一键脚本 + systemd,对外只记一个域名 | +| 中小团队 | 可控、低成本内部中台 | 不依赖外部 SaaS,数据握在自己手里 | +| 注重数据主权者 | 数据绝不能出本机 | 全程自托管,MIT 可改 | +| AI / 硬件爱好者 | 想玩智能体、嵌入式舵机 | aibox 智能体 + bot4cj 舵机 + lci 帧缓冲界面 | + +### 1.4 与云原生微服务的差异 + +未引入服务注册发现、容器编排、消息总线;服务地址在 `waf/config.yaml` 与 `gateway` 配置中静态配置。对于个人/中小规模 DIY 场景,这种取舍在简洁性与可控性上合理,但也意味着横向扩展需自行演进。 + +### 1.5 许可证与归属 + +各模块均采用 **MIT License**(详见各目录 `LICENSE`)。项目由个人开发者「qing」维护,仓库地址 `https://git.cancerai.top/qing/blackbird`。 + +--- + +## 2. 架构总览 + +### 2.1 UniServe 架构定义 + +以 `core`(C++20)为统一宿主进程,读取 `init.txt` 并经 shell 拉起多语言微服务。各服务通过内部 HTTP 对外暴露业务能力,通过 `pymsg/TCP`(自研 RPC)做数据访问与日志汇聚。 + +### 2.2 当前拓扑(含 ca 认证前置化) + +``` + ┌──────────────────────────────────────────────┐ + │ core (PMC / Nidus) │ + │ 进程托管服务 · 监听 :5200 (HTTP 管理接口) │ + │ 读取 init.txt 拉起并守护下列子进程 │ + └───────────────┬──────────────────────────────┘ + │ 启动 / 守护 + ┌──────────────┬──────────────┼──────────────┬──────────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────────┐ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ redirect80 │ │ waf │ │ ca │ │ gateway │ │ fsys │ │ dnfs │ + │ :80 重定向 │ │ :443 入口│ │ :8443 认证│ │ :8445 纯转发 │ │ :9200 前端 │ │ :8006 树状 │ + └──────────────┘ └────┬────┘ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ TLS终止 │ 鉴权+前置 │ 反向代理 │ 代理笔记 │ pymsg + │ coraza │ 转发 │ (loopback) │ │ + └──────────┴─────►───────┴──────────────────┴─────────────────┘ + 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 + 日志链路: 各服务 ──(log_client, pymsg)──► db:8005 (Logs 表,fail-safe 静默丢弃) + 配置链路: 业务服务 ──(pymsg get)──► confs:8010(mtime 自动重载) +``` + +> `db2` / `dnfs2` 分别是 `db` / `dnfs` 的 C++20 重实现,架构位置与 Python 版一致,接口兼容可平替。`BotOps`、`lci`、`bot4cj` 不在主图中(终端前端 / 帧缓冲界面 / 树莓派硬件,不联网)。 + +### 2.3 core 进程托管模型 + +- 可执行文件 `nidus.out`,以 systemd 单元 `Nidus.service` 常驻(崩溃守护由 systemd 负责,core 自身不含自动重启逻辑)。 +- 两种模式:命令行子系统(`--send/--exec/--list/--kill`)与 HTTP 伺服器(`--serve `)。 +- 经命名管道(Pipe)与消息队列(Mq)实现进程间通信;读 `init.txt` 自启动列表,按行拉起 ai_review / db / dnfs / smtp / static / waf / ca / gateway / captcha / aibox。 + +### 2.4 请求链路(8 步) + +1. 客户端以 HTTP 访问 `:80`,由 `redirect80` 重定向到 HTTPS `:443`(GET/HEAD 301,其余 308 保留 body)。 +2. 所有请求先打到 `waf:443`,终止 TLS、运行 coraza 规则,命中即 403。 +3. 未命中请求以明文 HTTP 转发到 `ca:8443`,透传 `X-Forwarded-For` / `X-Forwarded-Proto`。 +4. `ca` 对 `/login`、`/register` 做账号鉴权(PBKDF2 + RSA Token + 可选 CAPTCHA + 速率限制;注册经 pymsg/TCP 调 `smtp:8008` 发码),用户数据走 `db:8005`。 +5. 其余请求携带 Token,经 `ca` 验签后注入 `X-Auth-User` 转发到 `gateway:8445`(仅 loopback,仅做存在性检查),再由 gateway 反代到 `Proxy.opened` 放开的内部服务(`fsys:9200` / `dnfs:8006`)。 +6. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部调用。 +7. `dnfs` 处理节点 CRUD 时经 pymsg/TCP 调 `db` 持久化,新增节点前调 `ai_review` 审核。 +8. 各服务经 `log_client.py` 异步批量推送日志到 `db` 的 `Logs` 表(fail-safe)。 + +### 2.5 端口一览 + +| 端口 | 服务 | 绑定 | 说明 | +|------|------|------|------| +| 80 | redirect80 | 0.0.0.0 | HTTP→HTTPS 重定向 | +| 443 | waf | 0.0.0.0 | HTTPS 对外入口(TLS + coraza) | +| 8443 | ca | 127.0.0.1 | 中央认证(内部 HTTP) | +| 8445 | gateway | 127.0.0.1 | 纯转发反代(loopback) | +| 5200 | core | 127.0.0.1 | PMC HTTP 管理接口 | +| 9200 | fsys | 127.0.0.1 | 前端托管(经 gateway 代理) | +| 8006 | dnfs/dnfs2 | 127.0.0.1 | 树状菜单(经 gateway 代理) | +| 8005 | db/db2 | 127.0.0.1 | 数据库服务(pymsg/TCP) | +| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) | +| 8009 | captcha | 127.0.0.1 | 验证码(经 gateway 代理) | +| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用) | +| 8010 | confs | 127.0.0.1 | 统一配置(pymsg/TCP) | +| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) | + +--- + +## 3. 模块清单(按职责分组) + +### 3.1 核心与编排 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **core** | C++20 | 5200 | 进程托管服务 PMC/Nidus,读 `init.txt` 拉起守护子进程,提供 HTTP 管理接口(`--send/--exec/--list/--kill/--serve`) | + +核心设计:systemd 常驻、root 运行(见第 5 章风险)、Pipe/Mq 进程通信、无内置看门狗(崩溃自愈依赖 systemd)。 + +### 3.2 入口与网关层 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **redirect80** | Python | 80 | HTTP→HTTPS 重定向(GET/HEAD 301 / 其余 308),纯标准库 | +| **waf** | Go | 443 | 唯一对外入口,终止 TLS + coraza 规则匹配(SecLang)+ 反代到 ca;`kill -HUP` 热加载;规则库 `waf/rules/` | +| **ca** | C++20 | 8443 | 中央认证服务(v1.0.0,内部 HTTP),注册/登录鉴权、Token 签发校验、身份头注入后前置转发到 gateway | +| **gateway** | C++20 | 8445 | 应用层网关(v0.9.0,仅 loopback),纯转发反代,不验签(认证已迁至 ca) | + +**ca 关键设计**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节盐)、常数时间比较防 timing attack;Token 为 `Base64(payload)+"_"+RSA-Sign(payload)`,默认 1 小时过期,RSA 密钥启动时内存生成(重启即失效);`HttpOnly+Secure+SameSite=Lax` 的 `q4_token` Cookie;登录 5 次/5 分钟、注册 3 次/10 分钟速率限制;首位注册者即管理员(无默认管理员账号)。 + +**gateway 关键点**:纯转发,存在性不变量——显式 `Target-Url` 请求必须携带 ca 注入的 `X-Auth-User`,缺失一律 401;`Target-Url` 必须在 `Proxy.opened` 白名单。 + +### 3.3 业务服务(对外,经 gateway 代理) + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **fsys**(原 static) | Python/Flask | 9200 | 前端托管:社区首页/静态页/媒体流/文件浏览;**2026-08 由 static 重命名为 fsys**(core/init.txt 已同步),过渡期仍保留 legacy `static/` 目录 | +| **dnfs** | Python/Flask | 8006 | 树状菜单(Dendrachy Network File-System),基于 pid 的节点树 CRUD,新增节点前调 ai_review 审核 | +| **dnfs2** | C++20 | 8006 | dnfs 的 C++20 重实现(libhttp + libmsg),接口完全兼容,修复 P0/P1(fail-closed 验签/所有权校验/脱敏) | +| **ai_review** | Python/Flask | 8007 | AI 内容审核,DeepSeek + ChromaDB RAG(色情/违禁药/赌博/诈骗/仇恨暴力),fail-closed 宁可误拦 | + +### 3.4 基础设施 / 支撑服务(仅内部调用) + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **db** | Python/pymsg | 8005 | 通用数据库服务,SQL-over-TCP(SQLAlchemy Core 参数化防注入),内置集中式日志(Logs 表 + 自动清理) | +| **db2** | C++20 | 8005 | db 的 C++20 重实现(libmsg + boost::json + libsqlite3),`execute` 路由/where 语法/响应格式对齐,可平替 | +| **confs** | Python/pymsg | 8010 | 统一配置分发,集中存放各服务配置(`confs/configs/`),内存缓存 + mtime 自动重载,路径遍历防护 | +| **smtp** | Python/pymsg | 8008 | 邮件服务,注册邮箱验证码后端(send_email/verify_email/health),6 位码内存存储、冷却/限流/熔断 | +| **captcha** | Python/pymsg | 8009 | 验证码服务,图形验证码(一次性、5 分钟过期),经 ca 的 `GET /captcha/image` 代理给浏览器 | + +### 3.5 AI 智能体与前端 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **aibox** | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端;由 core 拉起但与主干微服务无调用关系 | +| **BotOps** | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 | +| **actpred** | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 | + +### 3.6 机器人硬件与嵌入式 + +| 模块 | 语言 | 端口 | 职责 | +|------|------|------|------| +| **bot4cj** | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 | +| **lci** | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 | +| **3d** | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 | + +### 3.7 共享基础库 + +| 库 | 语言 | 说明 | +|----|------|------| +| **libth** | C++ | 线程库:`Thread`、`Cv_wait`、`Fsm`、`ITask` | +| **libhttp** | C++ | HTTP/WebSocket 库(Boost.Beast):`HttpServer`(含 WS 服务/代理)、`HttpClient`(含 WS 客户端) | +| **libcrypt** | C++ | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` | +| **libsqlite3** | C++ | SQLite3 header-only 封装 | +| **libmsg** | C++ | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 载荷),`MsgClient/Server`、`RpcClient/Server`、`AsyncQueue`,与 Python pymsg 字节级兼容 | +| **libtry** | C | 嵌入式脚本解释器(~741 行),手写递归下降,支持中文标识符,用于嵌入式操作界面 shell | +| **pymsg** | Python | 自研 RPC 基础库(`[4B 长度][UTF-8 JSON]`),db 服务端与各 `log_client.py` 的基础依赖(setuptools 包) | +| **memdb** | C++ | 内存数据库(internal 组件,未在主 README 模块表列出) | + +### 3.8 测试与文档 + +| 项 | 说明 | +|----|------| +| **tests** | 黑盒测试套件(`test_db_*`/`test_gateway_*`/`test_waf_*`/`test_dnfs_*`),子进程 + HTTP 验证,前后自动备份恢复 db | +| **docs** | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析、INDEX.md),41+ 篇 md | + +--- + +## 4. 通信协议:pymsg / libmsg + +这是 Blackbird 异构架构的通信基石。 + +- **帧结构**:`[4 字节大端长度][UTF-8 JSON]`,单帧上限 **64 MiB**。 +- **跨语言一致**:Python `pymsg` 与 C++ `libmsg` 字节级兼容,是 v1/v2 双栈迁移的关键支撑。 +- **资源防护**:慢连接总 deadline 防护 + 有界线程池,控制资源耗尽类风险。 +- **错误脱敏**:`_sanitize_error` 以正则替换绝对路径,降低信息泄露面。 +- **安全现状(重要)**:协议**无握手、无鉴权、明文无 TLS**,安全性完全依赖 `127.0.0.1` 主机隔离。一旦内网隔离被突破(同机多租户或某内部服务失陷),所有跨进程调用即暴露。详见第 5 章与加固计划。 + +--- + +## 5. 安全体系 + +### 5.1 边界鉴权优势(已达 A-) + +gateway(及 ca)的边界鉴权设计扎实,安全评级由 **B+ 提升至 A-**(Gateway v0.8.0 修复 P0:错误消息脱敏、SIGTERM 退出、doAccept 无限递归、8 秒关闭超时): + +- 口令:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节盐),常数时间比较防时序攻击。 +- Token:RSA-2048 + PKCS#1 v1.5 签名,双轨吊销(jti 黑名单 + 用户版本号)。 +- 多层防护:CAPTCHA 一次性、登录 5/300s、注册 3/600s、邮箱级双窗口限流。 +- Cookie:`HttpOnly;Secure;SameSite=Lax`。 +- 管理面 fail-closed:`core` HTTP 接口绑 127.0.0.1 且 `verify_admin_token`。 + +### 5.2 已核实缺口(来自 2026-08-08 研究报告的审计结论) + +> 以下结论基于研究报告对当时代码快照的核实,整理时(2026-08-10)部分项已在 `ca` 引入、C++ 重写潮与加固计划中进入整改。**具体是否已在最新提交修复,需逐文件人工复核,状态标记为 `human_review_pending`。** + +| 级别 | 缺口 | 说明 | 关联整改 | +|------|------|------|----------| +| 高危 1 | WAF 规则失效 | `waf/rules/local/custom.conf` 的 6 条 SecRule 全部被注释,无 `crs/` 目录 → WAF 仅做 TLS 终止 + 响应头,不拦截 SQLi/XSS/命令注入 | 加固计划阶段一目标;待启用规则 | +| 高危 2 | core MQ 未鉴权即 exec | `parse_data_from_mq` 对 list/kill/exec 未鉴权即经 `/bin/sh -c` 执行;core 以 root 运行 → 本地 root RCE | 加固计划阶段二核心项 | +| 高危 3 | 内部服务零信任缺失 | pymsg 无认证;db 的 `execute` 路由接受任意 action/table/where,仅依赖 127.0.0.1 隔离 → 边界失守后可免认证读写全库 | 加固计划阶段三(AES-GCM) | +| 中危 1 | ai_review 未鉴权 + 提示注入 | `/review` 仅 per-IP 限流、无鉴权;用户输入直接拼入系统提示词 → 审核绕过 + 开放 LLM 端点 | aibox_open_dev_plan 跟踪 | +| 中危 2 | 密钥配置缺陷 | `.env.example` 写 `OPENAI_API_KEY` 而代码读 `DEEPSEEK_API_KEY` → 变量名不一致的静默失效 bug | 待对齐 | + +### 5.3 安全加固计划(三层防线) + +`docs/security/SECURITY_HARDENING_PLAN_20260803.md` 定义了"隔离层 + 认证层 + 加密层"三阶段目标: + +- **阶段一(隔离加固,P0)**:所有内部服务绑 127.0.0.1(✅ 已实现 db/confs/captcha/gateway/static/smtp/dnfs);防火墙仅放行 443、外网端口扫描验证仍 `[ ]` 待办。 +- **阶段二(core 密钥注入,P1)**:core 启动时生成进程组密钥,经 `execve` 的 envp 注入 `MICROSERV_KEY`,子进程 fail-fast 读取——仍 `[ ]` 待办。 +- **阶段三(AES-GCM 通信加密,P1)**:编写 `microcrypt` 库,接入 pymsg 帧与 HTTP 调用,防篡改/重放——仍 `[ ]` 待办。 + +### 5.4 安全审计覆盖(`docs/security/`,41+ 篇) + +包含全局审计(DEPLOY / DEPS / SECURITY_HARDENING_PLAN)与逐模块审计报告(waf、gateway×3、core、db、db2、pymsg、dnfs、dnfs2、static/fsys、ai_review、smtp、smtp3、captcha、captcha2、confs、libmsg、libhttp、libsqlite3、libth、aibox、BotOps、actpred)。`docs/reviews/` 另有 4 篇代码审查日志。可审计性远超多数同类项目,是风险闭环的基底。 + +--- + +## 6. 部署与运维 + +### 6.1 依赖 + +- **系统级**:`g++`(C++20)、`make`、`libboost-all-dev`、`libcurl4-gnutls-dev`、`libssl-dev`、`libsqlite3-dev`、`python3` + venv + pip、`patchelf`、`golang-go`(Go 1.22+,用于 waf)、`-ljpeg -lpng`(lci)。 +- **Python**:Flask、Flask-CORS、Flask-SQLAlchemy、SQLAlchemy、waitress、openai、chromadb、python-dotenv、nuitka。 +- **Go**:corazawaf/coraza v3.3.0、gopkg.in/yaml.v3。 + +### 6.2 安装链(一键脚本) + +```bash +# 1. 系统依赖 +bash _setup.deb.sh # Debian/Ubuntu +# 或 +bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora + +# 2. 依次构建安装(库先行,core 最后) +bash _inst.sh +``` + +单独安装某服务:`bash _set.sh`(构建+安装)、`bash _run.sh`(运行)。部署路径约定 `/opt/micro_servers/`。 + +### 6.3 运行 + +`init.txt` 自启动顺序重要(redirect80 → waf → gateway → …)。启动 core: + +```bash +systemctl start Nidus # 或 bash core/_run.sh +``` + +对外入口:`https://:443`(waf 终止 TLS 后转发)。 + +### 6.4 WAF 与 gateway 协作 + +启用 WAF 后证书从 gateway 迁到 waf:`cp gateway/full_chain.crt gateway/server.key waf/certs/`。gateway 改内部 HTTP 模式(`tls=off`、`address=127.0.0.1`、`port=8443`→现 8445)。独立部署(不用 WAF)时 gateway 自行 `tls=on`、`port=443`、`address=0.0.0.0`。 + +### 6.5 容器化现状 + +`docker-compose.yml` 的 services 块**仅纳入 db / ai_review / dnfs 三个服务**(另含 networks 与 volumes),core/waf/gateway/fsys/confs/captcha/smtp 未纳入 → 编排不完整。生产部署当前以 systemd 链路为准。 + +--- + +## 7. 测试与质量 + +- **黑盒测试**:`tests/blackbox/` 约 8 个集成测试,覆盖 db / dnfs / gateway / waf / static / captcha / smtp / ai_review;无单元测试、无 CI(仓库无 `.github/workflows`)。 +- **文档/审计完备度**:`docs/security/` 24+ 份逐模块审计 + `docs/reviews/` 评审日志,可审计性极高。 +- **双实现(v1/v2)重构负担**:db↔db2、dnfs↔dnfs2、pymsg↔libmsg、libtry↔libth、lci↔bot4cj 并存,正以「C++ 重写 Python 微服务」为目标迁移。任一逻辑修正需两语言同步,存在语义漂移风险——建议重构收口时优先合并单轨。 + +--- + +## 8. 开发路线图与近期重点(2026-08) + +### 8.1 C++ 重写潮(2026-08-09 引入) + +| 新增/重写模块 | 说明 | +|--------------|------| +| **ca** | 中央认证服务(C++ 2463 行),认证独立化,接管原 gateway 端口前置鉴权 | +| **captcha2** | C++ 重写 captcha(含 stb_image_write),修复 P0 大小写缺陷 | +| **confs2** | C++ 重写 confs(libmsg RpcServer 替代 pymsg),协议兼容零改动 | +| **smtp3** | C++ 重写 smtp(1013 行,SMTP_SSL 手写),审计 7 项遗留一并修复 | +| **core ConfsTask** | C++ 内嵌配置任务(557 行) | +| **gateway 精简** | `src/main.cpp` 大幅精简(-1980 行),认证逻辑下沉至 ca | + +### 8.2 认证独立化(auth_service_plan) + +`waf → ca → gateway` 前置拓扑:ca 统一鉴权并前置转发,gateway 退化为纯转发(仅 loopback),下游 / WAF / 前端零改动。对应开发文档见 `docs/reports/auth_service_plan.md`。 + +### 8.3 关键 dev docs 索引(`docs/reports/`) + +core_service_registry_dev_doc(注册中心)、auth_service_plan(CA 模块)、aibox_open_dev_plan / aibox_file_network_cli_refactor(aibox 安全沙箱与 CLI 化)、libcrypt_CBC_deprecation_dev_doc(AES-GCM 迁移)、libhttp_remote_addr_dev_doc(X-Remote-Addr 透传)、captcha_cpp_rewrite / smtp_cpp_rewrite / confs2_cpp_rewrite(C++ 重写方案)、actpred_pretrain_test(预训练与推理测试)。 + +### 8.4 安全评级演进 + +Gateway v0.8.0 修复 P0 后,**安全评级 B+ → A-**;随 ca 引入与 C++ 重写潮加固,整体向"端到端一致鉴权"收敛(详见第 5 章待办)。 + +--- + +## 9. 许可证与推广 + +- **许可证**:各模块 MIT。 +- **价值主张摘要**(详见 `docs/blackbird_promo_pack.md`): + - "微服务的好处,Kubernetes 的麻烦,二选一?Blackbird 说:都要,但只留好处。" + - "你的数据、你的服务、你的规则——一台机器,全部自己说了算。" + - Slogan 候选:*轻装微服务,主权握手中*;*一台服务器,一座可控的城*;*微服务的纯粹主义*。 + +--- + +## 10. 风险与待办(收口) + +| 风险 / 待办 | 级别 | 状态 | 来源 | +|------------|------|------|------| +| WAF 检测规则未启用 | 高危 | `human_review_pending`(计划阶段一目标) | 研究报告 / 加固计划 | +| core MQ 未鉴权 RCE(root 运行) | 高危 | `human_review_pending`(计划阶段二) | 研究报告 / 加固计划 | +| 内部 pymsg/db 零信任缺失 | 高危 | `human_review_pending`(计划阶段三) | 研究报告 / 加固计划 | +| ai_review 未鉴权 + 提示注入 | 中危 | `human_review_pending` | 研究报告 / aibox_open_dev_plan | +| 密钥变量名不一致(DEEPSEEK/OPENAI) | 中危 | `advisory`(待对齐) | 研究报告 | +| docker-compose 仅 3 服务,不完整 | P2 | `advisory` | 研究报告 | +| 无单元测试 / 无 CI | P2 | `advisory` | 研究报告 | +| v1/v2 双实现维护成本 | 结构性 | `advisory`(建议收口合并) | 研究报告 | +| 文档口径差异(MODULES 记 gateway:8443) | 低 | `advisory`(以 README 为准) | 本文档核实 | + +### 建议交付清单 + +- [ ] 启用 WAF 规则并补建 `crs/`(直击高危 1) +- [ ] 为 core MQ 通道落地 `verify_admin_token` 并降权运行(直击高危 2) +- [ ] 内部服务加调用方鉴权 / pymsg 上 mTLS 或 Unix socket(直击高危 3) +- [ ] ai_review `/review` 加鉴权 + 结构化提示词拼接 +- [ ] 统一 `DEEPSEEK_API_KEY` 与 `.env.example` +- [ ] 补全 compose 或文档声明 systemd 为唯一受支持链路 +- [ ] 引入单元测试与 CI,降低双实现回归风险 + +--- + +## 续接胶囊 + +- **文档类型**:项目综合资料长文档(Blackbird / micro_servers) +- **目标读者**:开发者、维护者、安全审计者、潜在贡献者 +- **核心目标**:将分散在 40+ 篇仓库文档中的架构、模块、协议、安全、部署、路线图整合为单一可查阅手册 +- **已完成章节**:第 0–10 章(概述、架构、模块清单、pymsg 协议、安全体系、部署运维、测试质量、路线图、许可证、风险收口) +- **已确认事实**:主 README 拓扑(waf:443→ca:8443→gateway:8445);ca 为 C++ 认证前置模块;C++ 重写潮(ca/captcha2/confs2/smtp3/gateway 精简/core ConfsTask);安全评级 B+→A-;MIT 许可 +- **待核验事项**:3 项高危 + 2 项中危是否已在最新提交修复(研究报基于 2026-08-08 快照);docker-compose 补全状态;MODULES.md 端口口径同步 +- **下一节唯一目标**:(可选)补充"各模块 API 速查表"或"从零部署实战 walkthrough" +- **续写口令**:`继续编辑 docs/blackbird_project_profile.md,当前停于第 10 章风险收口;已含架构/模块/协议/安全/部署/路线图;约束:以主 README 拓扑为准、高危缺口标注 human_review_pending、不虚构修复状态;下一目标:补模块 API 速查表或部署实战。` diff --git a/docs/reports/fsys_public_frontend_research_20260810.md b/docs/reports/fsys_public_frontend_research_20260810.md new file mode 100644 index 0000000..a86c6fe --- /dev/null +++ b/docs/reports/fsys_public_frontend_research_20260810.md @@ -0,0 +1,141 @@ +# fsys/public 前端代码研究报告 + +> 日期:2026-08-10 · 范围:`fsys/public/` 全部前端代码(**不含 `games/` 目录**) · 托管方:fsys(C++20,127.0.0.1:9200) + +--- + +## 一、总览 + +fsys/public 是 UniServe 框架对外的全部 Web 前端,由 fsys 静态托管,经 `waf:443 → ca:8443 → gateway:8445 → fsys:9200` 链路送达浏览器。整套前端为**零构建、零框架的原生 HTML/CSS/JS**(Vanilla 技术栈),无打包器、无 npm 依赖,第三方库仅两个 vendored 文件(markdown-it 12.0.0、highlight.js 11.9.0)。 + +### 代码规模(不含 games/) + +| 类别 | 文件 | 行数 | 说明 | +|------|------|------|------| +| 自研 JS | 8 个 | ~2,650 | 业务逻辑主体 | +| 第三方 JS | 2 个 | ~9,500 | markdown-it / highlight.js(vendored,不分析) | +| CSS | 8 个 | ~2,370 | 含设计令牌体系 theme.css | +| HTML | 13 个 | ~2,100 | 含 allcolor.html 内联数据 ~400 行 | +| 合计(自研) | 26 个 | **~7,100** | | + +### 页面地图 + +``` +public/ +├── index.html 社区首页( landing,Hero + 特性 + 关于 + CTA ) +├── login.html 登录(图形验证码 + 用户条例弹窗 + 第三方 yhdet 快捷登录) +├── register.html 注册(图形验证码 + 邮箱验证码双因子) +├── forgot.html 重置密码(同注册双因子流程,脚本内联) +├── dnfs.html 树状笔记主应用(核心 SPA 式单页) +├── aibox.html aibox 智能体 Web 客户端(WebSocket,仅管理员) +├── __view.html 目录浏览页(fsys 无 index.html 时的回退页,__ 前缀被 filelist 过滤) +├── allcolor.html 中国传统色彩调色板(纯静态工具页,内联数据) +├── LICENSE.html 用户使用条例(静态文本,供登录页 iframe 弹窗复用) +├── mgmt/ 进程管理器(管理员后台,iframe 嵌套 features/*.html) +│ ├── index.html 旧版独立登录页(疑似遗留,见 §六问题 P1) +│ ├── mgmt.html 管理主框架(左侧功能列表 + iframe 加载功能页) +│ └── features/ process(可用) / user / module / log(占位) +├── css/ theme / index / auth / dnfs / view / aibox / codeblock(空) +└── js/ token_manager / validation / index / login / register / dnfs / aibox / view +``` + +--- + +## 二、认证体系:HttpOnly Cookie + Token 生命周期管理 + +整套前端已在 2026-08 完成向 **HttpOnly Cookie(q4_token)** 的迁移,JS 全程不经手 Token,这是本次研究中最重要的一条架构主线。 + +### 2.1 token_manager.js(119 行,全局基础设施) + +被 index / dnfs / aibox 三页引用,做两件事: + +1. **定时静默刷新**:通过 `POST /whoami` 取 `created_at + keep_time` 估算过期点(注释明确指出不能用 `iat`——刷新后 iat 不变),在 80% 生命周期处调 `POST /refresh_token`;失败进入 15s 冷却重估,两次刷新最小间隔 60s。 +2. **全局 fetch 拦截器**:覆写 `window.fetch`,任何请求收到 401 先自动刷新一次 Token 再重试原请求;`/refresh_token` 自身排除防循环。Cookie 由 gateway `Set-Cookie` 自动更新,重试无需改请求头。 + +### 2.2 各页登录态处理 + +| 页面 | 检测方式 | 401 处理 | +|------|----------|----------| +| index | `POST /whoami`,已登录则切换头部按钮组与 CTA 文案 | 静默忽略(首页允许匿名) | +| dnfs | `loadUserPanel()` 拉 `/whoami`,按 `is_admin` 显示特殊节点面板与管理入口 | 仅笔记接口 401 跳 login.html,其他错误原地提示(不再误判凭证失效) | +| aibox | 启动预检 `/whoami`,非管理员仅提示不阻塞 | WS 握手由 gateway 强制 is_admin 兜底 | +| mgmt | `mgmt.html` 内联 `loadUserInfo()`,未登录 1s 后跳 `index.html` | features/process.html 遇 401 弹窗后跳登录 | + +### 2.3 注册/登录/重置三流程 + +- **login**:邮箱 + 密码 + 图形验证码(`GET /captcha/image`,token 走 `X-Captcha-Token` 响应头,图片 blob → ObjectURL,旧 URL 主动 revoke)。无论成败提交后强制刷新验证码(token 一次性消费)。另有 yhdet 开源社区第三方快捷登录入口(外跳 callback)。 +- **register**:图形验证码 → `POST /register/send_code` 发 6 位邮箱验证码(10 分钟有效,前端 60s 重发倒计时与服务端冷却对齐,HTTP 429 时也启动倒计时)→ `POST /register` 提交。 +- **forgot**:与 register 几乎同构,走 `/reset/send_code` + `/reset_password`;脚本**直接内联在 HTML 里**(339 行中约 290 行是 register.js 的复制改造)。 +- **WafTip**(validation.js):统一识别 WAF 拦截特征(403 + 纯文本 `blocked by WAF`),与 gateway 自身的 JSON 格式 403 区分,三处表单均接入。 + +--- + +## 三、dnfs 树状笔记(dnfs.html + dnfs.js 1187 行,核心应用) + +### 3.1 导航模型 + +- 用 `stack` 数组记录节点路径(节点 id 栈,根为 0),点卡片标题 push、返回 pop,配合 `getNode(id)` 拉子节点列表。 +- `Target-Url: http://127.0.0.1:8006` 请求头由 gateway 转发到 dnfs2。 +- **导航守卫** `confirmLeaveIfDirty()`:表单有未提交内容时拦截导航(明确注释强调必须在改 stack 之前调用,否则栈错位)。 +- 右悬浮 **minimap**(当前层节点锚点跳转 + 折叠),左悬浮 **userpanel**(用户名/角色/特效开关/管理入口/退出),窄屏(<768px)默认折叠。 + +### 3.2 内容渲染管线 + +- 普通节点:markdown-it(`html: false`)+ highlight.js 代码高亮。 +- **特殊节点**(管理员专属,信封格式 `{"_sp":"link|js|image|file", ...}`): + - `link`:favicon 经服务端代理 `/api/favicon?url=`(SSRF 防护在 fsys 侧),新标签 `noopener noreferrer`。 + - `js`:可审计/可运行的前端特效,**iframe `sandbox="allow-scripts"` + srcdoc** 隔离执行,不用 eval/new Function(CSP 未开 unsafe-eval);`` 标记决定挂在卡片内还是 fixed 全屏(注释详细论证了层叠上下文陷阱);用户面板提供"无特效模式"开关(localStorage 持久化)。 + - `image`:DataURL 直显;`file`:base64 → Blob 触发下载;上传上限 2.5MB(与后端 3.5M base64 字符上限对齐)。 +- 权限显隐:发布权限后端强制(is_admin),前端仅按 `can_operate` 字段渲染编辑/移动/删除按钮组。 + +### 3.3 其他特性 + +- 当前层实时搜索(标题/owner/内容,信封类排除 base64),ESC 清空,无匹配有提示。 +- 节点移动 = pid 变更的 update_note,底部 movebar 二次确认;移动/发布会走后端 AI 审核,期间按钮组禁用防重。 +- 互动统计图标(观看/点赞/分享)已就位,但后端 Notes 表无计数字段,恒显示 0(预留)。 +- 大量防御性注释记录了历史 bug 修复(空栈取 `stack[-1]` 得 undefined、confirm 取消时 Promise 兜底恢复按钮等)。 + +--- + +## 四、aibox Web 客户端(aibox.html + aibox.js 435 行) + +- **WebSocket** 经 gateway `[WsProxy]` 反代至 aibox 服务(`/ws/aibox`),协议与 BotOps/aibox `client.py` 完全兼容。 +- 完整流式渲染:chunk 增量拼接 + markdown-it 渲染;reasoning 思考区可折叠、结束后默认折叠、超长仅保留末尾 3000 字符;`ai_break` 支持工具调用分段气泡;中断(interrupt)有完整状态收尾。 +- 连接韧性:指数退避重连(2s→30s 封顶)、10s 心跳(低于前置代理 proxy_read_timeout)、手动断开抑制重连。 +- 任务面板为抽屉式覆盖层,未读角标(99+ 封顶);本地命令 `/clear /help /connect /disconnect /interrupt`,其余 `/` 命令透传服务端。 +- 移动端细节:`enterkeyhint="send"`、`isComposing` 防中文输入法误发、viewport-fit=cover 刘海屏适配。 + +## 五、__view 目录浏览(__view.html + view.js) + +fsys 对无 index.html 目录的回退浏览页(`__` 前缀使其被 `/api/filelist` 过滤,不出现在列表里)。面包屑 + 搜索(`/` 或 Ctrl+F 聚焦、200ms 防抖、ESC 清空)+ 分类统计(目录/文件/视频/音频)+ 网格列表;视频音频跳 `/video/`(fsys Range 206 流式),`?download=true` 触发下载。所有文件名经 `esc()` 转义防 XSS。 + +## 六、mgmt 进程管理器(管理员后台) + +- `mgmt.html`:顶栏用户信息 + 左侧功能列表 + iframe 加载功能页;功能列表中日志页已被注释停用。 +- `features/process.html`:**唯一真正可用的功能页**,经 gateway 代理 core:5200,实现进程列表(兼容 boost property_tree 把数组序列化成对象的怪癖)、新建(/exec)、停止(/kill)、401 跳登录。 +- `features/user.html` / `module.html` / `log.html`:占位静态页,按钮仅 alert"需要后端 API 支持"。 + +## 七、CSS 设计体系 + +- **theme.css(686 行)是全站基座**:完整 Design Tokens(`--bg/--fg/--accent/--error...` 约 24 个变量),`prefers-color-scheme` 暗色全套覆写,暖棕橙品牌色(accent `#e8853a`),统一表单/按钮/提示(tip-red/yellow/green)/卡片/页脚,480px/360px 两档响应式。 +- 页面级 CSS 在其上叠加:index.css(landing 各 section + 滚动渐显 + 光标光斑)、auth.css(注册/重置页品牌渐变顶条)、dnfs.css(点阵底纹、卡片逐张入场动画、`prefers-reduced-motion` 降级)、view.css / aibox.css(全屏应用布局覆盖 theme 居中卡片默认)。 +- 一致性良好:动效全部尊重 `prefers-reduced-motion`,悬浮面板统一 backdrop-filter 毛玻璃,暗黑模式覆盖完整。 + +## 八、发现的问题与改进建议 + +| # | 级别 | 位置 | 问题 | +|---|------|------|------| +| P1 | 中 | mgmt/index.html + mgmt.html | **旧认证体系遗留**:自建 `/api/login` + JS 写 `token=` Cookie(非 HttpOnly q4_token),logout 删的也是 `token` Cookie,与现行认证完全脱节;该登录页应删除或改造,避免成为绕过口 | +| P2 | 中 | js/view.js | 文件名经 `esc()` HTML 转义后仍**拼进 `onclick="navigate('${path}')"` 内联字符串**:`'` 在属性解析时会解码回单引号,单引号文件名可断开 JS 字符串造成注入。建议改 addEventListener + dataset | +| P3 | 低 | register.js / forgot.html | `POST /register`、`/reset_password` 声明 `Content-Type: application/x-www-form-urlencoded` 但 body 实为 JSON 字符串(后端兼容故能用,契约不洁) | +| P4 | 低 | login.js / register.js / forgot.html | refreshCaptcha / startResendCooldown / 校验三件套**复制了三份**,forgot 干脆内联 290 行;建议抽 `js/auth_common.js` | +| P5 | 低 | js/dnfs.js | 非严格模式下 `obj/jsonBody` 等多处未声明即赋值(隐式全局);`card.id = item.title` 标题重复或含特殊字符时锚点冲突 | +| P6 | 低 | 空文件 | `css/codeblock.css`(0 行,dnfs.html 仍引用)、`mgmt/features/log.css`(0 行) | +| P7 | 低 | mgmt/features/{user,module,log}.html | 占位页无实现,入口仍展示"模块/用户"按钮,建议隐藏或标注 | +| P8 | 提示 | dnfs.js 统计图标 | visit/like/share 前端已渲染但后端无字段,恒为 0 | + +## 九、结论 + +fsys/public 前端在零框架约束下达到了相当高的完成度:**认证设计现代化**(HttpOnly Cookie + 静默刷新 + 401 拦截重试)、**安全细节扎实**(WAF 特征识别、验证码一次性消费、iframe 沙箱特效、SSRF 防护代理、favicon blob 回收)、**可访问性与移动端适配到位**(reduced-motion、enterkeyhint、isComposing、safe-area)。注释质量突出,大量历史 bug 的成因与防御约定都沉淀在代码里。 + +主要欠账集中在 **mgmt 子系统**(旧认证遗留 + 三个占位页)和**三处表单脚本的复制粘贴**。优先级建议:P1(旧登录页拆除)> P2(view.js 内联事件注入)> P4(公共脚本抽取)> 其余。 From 2959197597f5f900f3a3c630486ffbdbbc320992 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 01:55:58 +0800 Subject: [PATCH 011/196] =?UTF-8?q?docs(dnfs):=20=E5=B0=86=E5=B7=B2?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A1=E8=AE=A1=E9=A1=B9=E5=90=88=E5=85=A5?= =?UTF-8?q?=20dnfs.py=20=E6=B3=A8=E9=87=8A=E5=B9=B6=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dnfs/dnfs.py | 16 ++ docs/INDEX.md | 4 +- .../security/dnfs2_SECURITY_AUDIT_20260808.md | 2 + docs/security/dnfs_SECURITY_AUDIT_20260801.md | 212 +++++++++++------- 4 files changed, 148 insertions(+), 86 deletions(-) diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 80aa446..4e5d8f7 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -309,6 +309,15 @@ apply_security_headers(app) CORS(app) +# [SECURITY-AUDIT] P0-1 / P1-5 —— 已修复(审计结论见 docs/security/dnfs_SECURITY_AUDIT_20260801.md) +# 原问题: +# · P0-1 Token 无 RSA 签名验证,攻击者可伪造任意身份(含管理员)。 +# · P1-5 Token 无过期校验,被盗 Token 可长期复用。 +# 修复方式(本项目统一安全决策):本服务不持有密钥,Token 校验全部上移至 gateway。 +# decode_token 转发 gateway /whoami,由 gateway 完成 RSA 验签 + 基于 +# created_at+keep_time 的过期校验;gateway 不可达 → fail-closed 抛 503, +# 验签失败/未登录 → 返回 None(调用方转 401)。is_admin 由 gateway 基于 +# [Admin] 白名单在 Token payload 写入,本服务只读,不再本地判定。 def decode_token(token: str): """通过 gateway /whoami 接口验证 Token 并返回用户信息。 @@ -465,6 +474,12 @@ def get_notes(): # eg: {"id": 1} +# [SECURITY-AUDIT] P0-3 / P1-10 —— 已修复 +# 原问题: +# · P0-3 delete_note / update_note 原缺 @_handle_exp 装饰器,异常冒泡成 Flask 默认 500 页。 +# · P1-10 上述函数内 decode_token 等调用无 try-except,异常直接裸抛 500。 +# 修复:两函数均加 @_handle_exp(定义见 L294),DnfsExp → 对应状态码、 +# 其余 Exception → 500,统一返回结构化 JSON,不再泄露栈信息。 @app.route('/delete_note', methods=['POST']) @_handle_exp def delete_note(): @@ -568,6 +583,7 @@ def delete_note(): # "title": "test", # "content": "hello" #} +# [SECURITY-AUDIT] P0-3 / P1-10 —— 同 delete_note,已加 @_handle_exp 统一异常(见上)。 @app.route('/update_note', methods=['POST']) @_handle_exp def update_note(): diff --git a/docs/INDEX.md b/docs/INDEX.md index c4a40df..723484e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -43,8 +43,8 @@ docs/ | db | [db_SECURITY_AUDIT_20260801.md](security/db_SECURITY_AUDIT_20260801.md) | Python 数据库服务(pymsg、SQLAlchemy、日志收集) | | db2 | [db2_SECURITY_AUDIT_20260806.md](security/db2_SECURITY_AUDIT_20260806.md) | C++20 数据库 RPC 服务(db 的 C++ 重实现,SQLite C API) | | pymsg | [pymsg_SECURITY_AUDIT_20260801.md](security/pymsg_SECURITY_AUDIT_20260801.md) | Python RPC 基础库(长度前缀帧、MsgClient/Server) | -| dnfs | [dnfs_SECURITY_AUDIT_20260801.md](security/dnfs_SECURITY_AUDIT_20260801.md) | Python 树状菜单服务 | -| dnfs2 | [dnfs2_SECURITY_AUDIT_20260808.md](security/dnfs2_SECURITY_AUDIT_20260808.md) | C++20 树状菜单服务(dnfs 重实现) | +| dnfs | [dnfs_SECURITY_AUDIT_20260801.md](security/dnfs_SECURITY_AUDIT_20260801.md) | Python 树状网络文件系统(**已废弃,生产用 dnfs2**;2026-08-10 复核为全量 16 项发现) | +| dnfs2 | [dnfs2_SECURITY_AUDIT_20260808.md](security/dnfs2_SECURITY_AUDIT_20260808.md) | C++20 树状网络文件系统(dnfs 重实现,**生产在跑**,评级 A) | | static | [static_SECURITY_AUDIT_20260801.md](security/static_SECURITY_AUDIT_20260801.md) | Python 前端托管/文件服务 | | fsys | [fsys_SECURITY_AUDIT_20260808.md](security/fsys_SECURITY_AUDIT_20260808.md) | Python 文件系统服务 | | ai_review | [ai_review_SECURITY_AUDIT_20260801.md](security/ai_review_SECURITY_AUDIT_20260801.md) | Python AI 内容审核(DeepSeek + RAG) | diff --git a/docs/security/dnfs2_SECURITY_AUDIT_20260808.md b/docs/security/dnfs2_SECURITY_AUDIT_20260808.md index 029ae4a..7b3bca9 100644 --- a/docs/security/dnfs2_SECURITY_AUDIT_20260808.md +++ b/docs/security/dnfs2_SECURITY_AUDIT_20260808.md @@ -6,6 +6,8 @@ **审计轮次**: 第 2 轮(第 1 轮发现 5 项已全部修复) **安全评级**: A +> 📌 **本服务为 dnfs 的 C++20 生产实现**,`core/init.txt` 实际启动的是 `dnfs2`(Python dnfs 已废弃,历史审计见 [`dnfs_SECURITY_AUDIT_20260801.md`](dnfs_SECURITY_AUDIT_20260801.md),2026-08-10 已复核为全量 16 项发现)。本审计覆盖生产代码,结论有效。 + --- ## 1. 审计概述 diff --git a/docs/security/dnfs_SECURITY_AUDIT_20260801.md b/docs/security/dnfs_SECURITY_AUDIT_20260801.md index 5cc8eeb..eef920b 100644 --- a/docs/security/dnfs_SECURITY_AUDIT_20260801.md +++ b/docs/security/dnfs_SECURITY_AUDIT_20260801.md @@ -1,112 +1,156 @@ -# dnfs 模块安全审计报告 +# dnfs 模块安全审计报告(Python 版) -**审计日期**: 2026-08-01 -**审计范围**: `dnfs.py`、`security_middleware.py`、`log_client.py` -**代码版本**: dev_qing 分支 +**原始审计日期**: 2026-08-01 +**代码实证复核(最后更新)**: 2026-08-10 +**审计范围**: `dnfs/dnfs.py`(723 行)、`security_middleware.py`、`log_client.py` +**代码版本**: dev_qing 分支 +**历史评级**: B(2026-08-01 审计) --- -## 代码验证记录 - -**验证日期**: 2026-08-03 -**验证方式**: 逐条对照审计报告与当前代码(`dnfs.py`、`security_middleware.py`、`log_client.py`) - -### 验证结果汇总 - -| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 | -|------|------|--------|----------|--------|--------| -| P0 | 1 | 1 | 0 | 0 | 100% | -| P1 | 2 | 2 | 0 | 0 | 100% | -| P2 | 2 | 0 | 0 | 2 | 0% | -| **合计** | **5** | **3** | **0** | **2** | **60%** | - -### 逐条验证 - -| 编号 | 状态 | 代码位置 | 验证说明 | -|------|------|----------|----------| -| P0-1 | ✅ 已修复 | `dnfs.py` L312-351 | `decode_token` 完全重写:不再本地解码,改为调用 gateway `/whoami` 接口,由 gateway 完成 RSA 签名验证和过期检查;网络异常 fail-closed(返回 503) | -| P1-1 | ✅ 已修复 | `dnfs.py` L312-351 | Token 过期校验由 gateway `/whoami` 统一处理,dnfs 不再自行校验 | -| P1-2 | ✅ 已修复 | `dnfs.py` L348-351 | `is_admin` 由 gateway 服务端判定(基于 Admin 白名单),dnfs 信任 gateway 响应,不再从用户提交的 Token 中直接读取 | -| P2-1 | ❌ 未修复 | `dnfs.py` L156-202 | `review_content` 已实现 fail-closed(所有异常路径均拒绝请求),但返回类型不一致(bool vs dict),需代码清理 | -| P2-2 | ❌ 未修复 | `dnfs.py` L468-562 | `delete_note` 无确认机制,无速率限制 | +> ## ⚠️ 废弃声明 +> +> **Python dnfs 已废弃,生产环境实际运行的是 C++20 重写版 `dnfs2`**(见 [`dnfs2_SECURITY_AUDIT_20260808.md`](dnfs2_SECURITY_AUDIT_20260808.md),评级 **A**)。`core/init.txt` 第 3 行启动的是 `dnfs2/_run.sh`,Python dnfs 不在运行清单内。 +> +> 本审计仅覆盖 **Python dnfs 的历史代码状态**,用于溯源与和 dnfs2 的修复对照。**不要在线上启用 Python dnfs**——它的多数 P0/P1 安全问题未修复(见下方清单),且这些问题在 dnfs2 中已全部解决。 --- -## 修复进度 +## 1. 模块定位与状态 -| 优先级 | 总数 | 已修复 | 待修复 | -|--------|------|--------|--------| -| P0 | 1 | 1 | 0 | -| P1 | 2 | 2 | 0 | -| P2 | 2 | 0 | 2 | +dnfs = **Dendrachy Network File-System**,基于父子关系(`pid`)组织的树状节点服务,监听 `127.0.0.1:8006`,提供节点的增删改查,写操作需 gateway 签发的 Token。 ---- +- **Python dnfs(本审计对象)**:Flask + Waitress,历史实现,已废弃。 +- **C++ dnfs2(生产实现)**:libhttp + libmsg + boost::json,接口兼容,安全基线显著提升(评级 A)。 -## 🔴 P0 — 严重问题 +接口(4 个 `POST`):`get_notes`(无鉴权)、`add_note`、`delete_note`、`update_note`(后三者需 Bearer Token;add/update 写入前经 `ai_review` 内容审核)。 -### P0-1: Token 无 RSA 签名验证,可伪造任意用户身份(含管理员) ✅ 已修复 -- **位置**: `dnfs.py:312-351`(完全重写) -- **原问题**: `decode_token` 本地 base64 解码 Token,代码明确标注 `FIXME: 验证数字签名`,但完全跳过了 RSA 签名验证,攻击者可伪造任意用户身份(含管理员) -- **修复方案**: `decode_token` 完全重写,改为调用 gateway `/whoami` 接口: - 1. 将 Token 通过 `Authorization: Bearer` 头发送给 gateway - 2. Gateway 服务端完成 RSA 签名验证 + 过期检查 - 3. 返回 `{"login": true/false, "user": "...", "is_admin": true/false, ...}` - 4. 网络异常(gateway 不可达)→ fail-closed,返回 503 - 5. 验签失败(`login=false`)→ 返回 None,调用方返回 401 -- **影响范围**: 已消除,所有需要认证的端点(add_note、delete_note、update_note)均通过 gateway 验证 +## 2. 架构与数据流 ---- +``` +客户端 → waf:443 → ca:8443(认证) → gateway:8445(纯转发, loopback) + → dnfs2:8006 ──pymsg──► db2:8005 (execute 路由, 落 SQLite Notes 表) + └──HTTP──────► ai_review:8007 (add/update 写入前内容审核) +``` -## 🟠 P1 — 高危问题 +- **Token 验签**:dnfs 不持有密钥,统一转发 `gateway /whoami` 由 gateway 完成 RSA 验签 + 过期校验;gateway 不可达 → fail-closed 返回 503。 +- **数据操作**:经 pymsg RPC 调用 db 服务 `_route: "execute"`,不直接连库(规避 SQL 注入)。 +- **特殊节点(管理员专属)**:`content` 为 `{"_sp":"link|js|image|file",...}` 信封时前端差异化渲染;仅 `is_admin` 可发布。 -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P1-1 | Token 过期校验不严谨 | ✅ 已修复 | Token 过期校验由 gateway `/whoami` 统一处理(基于 `created_at` + `keep_time`),dnfs 不再自行校验 | -| P1-2 | is_admin 权限完全依赖 Token 字段,无二次验证 | ✅ 已修复 | `is_admin` 由 gateway 服务端基于 Admin 白名单判定,dnfs 从 gateway 响应中读取(`info.get('is_admin')`),不再从用户提交的 Token 直接读取 | +## 3. 审计结论与评级 ---- +| 等级 | 原始审计(2026-08-03 验证) | 当前代码实证(2026-08-10) | +|------|---------------------------|---------------------------| +| P0 | 1 项,已修复 1 | 2 项:部分修复 1,未修复 1* | +| P1 | 2 项,已修复 2 | 5 项:未修复 5 | +| P2 | 2 项,未修复 2 | 5 项:全部未修复 | -## 🟡 P2 — 中危问题 +\* P0-4(父节点删除 TOCTOU)在 Python dnfs 中仍**未修复**,但其"未先检查 `success`"的子问题在 dnfs2 中已修,仅残余 TOCTOU 窗口(需 db2 事务支持)。 -| # | 问题 | 状态 | 说明 | -|---|------|------|------| -| P2-1 | AI 审核 fail-closed 实现需验证 | ❌ 未修复 | `review_content` 已实现 fail-closed(HTTPError/URLError/JSONDecodeError 均导致请求被拒),但函数返回类型不一致(bool vs dict),建议代码清理 | -| P2-2 | 节点删除无确认机制,批量删除无速率限制 | ❌ 未修复 | `delete_note` 无二次确认,无速率限制 | +**评级说明**:Python dnfs 历史评级为 **B**(基于 2026-08-01 仅 5 项发现的快照)。按当前代码实证的全量 16 项发现,若仍上线则应下调评级——其 P0-4(树结构完整性)、P1-6/7/8/9/11(请求体、配置一致性、父节点校验、启动等待、类型转换)等高危问题均未修复。**正确做法是不再启用 Python dnfs,统一使用 dnfs2。** ---- +## 4. 完整发现清单(代码实证,2026-08-10) -## ✅ 已有安全机制 +> 编号沿用 `dnfs/README.md`「已知问题与风险」的权威枚举(P0-1~4 / P1-5~11 / P2-12~16,共 16 项)。 +> 状态依据对 `dnfs/dnfs.py` 与 `log_client.py` 的逐行核对。 +> **已合入代码注释(dnfs.py)并移出本节的项**:P0-1、P0-3、P1-5、P1-10(均为 ✅ 已修复/已解决),其安全决策见对应代码注释。本节仅保留未修复/部分修复的开放项,共 12 项。 -1. ✅ **特殊节点校验**: link/html/image/file 各类型有格式与大小限制(URL 前缀、大小上限) -2. ✅ **AI 内容审核集成**: 新增节点前调用 ai_review 服务审核 -3. ✅ **security_middleware.py**: 安全响应头(CSP、X-Frame-Options、X-XSS-Protection 等) -4. ✅ **CORS 配置**: 跨域控制 -5. ✅ **通过 pymsg 调 db 服务**: 不直连数据库,规避 SQL 注入 +### 🔴 P0 — 严重问题 -## 新增安全机制(审计后添加,审计报告未覆盖) +| 编号 | 问题 | 状态 | 代码位置 | 说明 | +|------|------|------|----------|------| +| P0-2 | `review_content()` fail-closed 与返回类型不一致 | ⚠️ 基本修复 | `dnfs.py:156-202` | 异常路径(HTTPError/URLError/JSONDecodeError)均拒绝请求;但 HTTPError 且响应体为合法 JSON 时返回 **dict**(L196 `return json.loads(error_body)`),与成功路径返回的 **bool** 类型不一致,存在 `True != dict` → 一律拦截(仍 fail-closed,但类型契约不干净,建议清理) | +| P0-4 | 有子节点的父节点可能被删除(树结构完整性) | ❌ 未修复 | `dnfs.py:510-525` + `db/create_sqlite3.sql:13-19` | 应用层有子节点检查(`if len(notes['data']) > 0: return 400`),但:① **数据库层 `Notes.pid` 无外键约束**;② 子节点查询与删除间存在 **TOCTOU 竞态**;③ `notes['data']` 在 **未先确认 `success`** 时访问(L519),`success=False` 时抛 KeyError | -| 机制 | 代码位置 | 说明 | -|------|----------|------| -| Token 验签委托 gateway | `dnfs.py` L312-351 | `decode_token` 调用 gateway `/whoami` 完成 RSA 签名验证 + 过期检查,dnfs 不再持有/管理密钥 | -| Token 验签 fail-closed | `dnfs.py` L336-339 | gateway 不可达时返回 503,拒绝所有认证请求 | -| 配置白名单合并 | `dnfs.py` L55-63 | `_merge_ini` 仅覆盖已知 section/key,防止意外字段注入 | -| 配置完整性校验 | `dnfs.py` L100-112 | 启动时检查所有必需配置项,缺失则退出 | -| 特殊节点审核脱敏 | `dnfs.py` L273-286 | `_sp_review_text` 剔除 base64 负载(体量大且无审核意义),仅保留文本字段送审 | -| 统一异常处理 | `dnfs.py` L294-305 | `_handle_exp` 装饰器统一捕获 DnfsExp 和通用异常,返回结构化错误响应 | -| 异步日志 fail-safe | `log_client.py` L66-72, L147-148 | AsyncRpcQueue 后台异步推送,db 不可用时静默丢弃,不阻塞业务 | +### 🟠 P1 — 高危问题 ---- +| 编号 | 问题 | 状态 | 代码位置 | 说明 | +|------|------|------|----------|------| +| P1-6 | 请求体大小无限制 | ❌ 未修复 | `dnfs.py:723` | `serve(app, host=_ADDR, port=_PORT)` 未配置 `max_request_body_size`,建议与 WAF 对齐设 10MB | +| P1-7 | `log_client.py` 配置与主模块不一致 | ❌ 未修复 | `log_client.py:32-33` | 日志客户端直读 `DB_HOST`/`DB_PORT` 环境变量,不拉取 confs,与主模块配置源不一致(dnfs2 已复用主配置修复) | +| P1-8 | `update_note` 改 pid 时未校验目标父节点存在 | ❌ 未修复 | `dnfs.py:571-673` | `update_note` 直接更新 `pid`,当 `pid≠0` 时不检查父节点是否存在(dnfs2 已用 `_note_exists` 校验) | +| P1-9 | db RPC 客户端未启用启动等待 | ❌ 未修复 | `dnfs.py:133` | `_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")` 无 `retry_on_refused=True`;db 未就绪时相关 API 直接失败(dnfs2 已加 `retry_on_refused=true`) | +| P1-11 | `note_id` 缺少强制类型转换 | ❌ 未修复 | `dnfs.py:474,577` | 仅类型提示 `note_id: int = data.get('id')` / `id: int = data.get('id')`,依赖 SQLite 类型亲和性隐式匹配(dnfs2 已用 `get_int()` + `is_int64()` 强校验) | -## 修复路线图 +### 🟡 P2 — 中危问题 -### 立即(24小时) -- [x] 实现 Token RSA 签名验证 ✅ 已完成(委托 gateway) +| 编号 | 问题 | 状态 | 代码位置 | 说明 | +|------|------|------|----------|------| +| P2-12 | 错误响应泄露内部信息 | ❌ 未修复 | `dnfs.py:371,395,400,420,523,540,561,615` | 错误消息拼接 `request.remote_addr` 与用户名返回客户端(如 `未授权访问,从{ip}`、`特殊节点仅管理员可发布,用户{user},从{ip}`);应仅记服务端日志(dnfs2 已清除此类泄露) | +| P2-13 | CORS 配置过于宽松 | ❌ 未修复 | `dnfs.py:309` | `CORS(app)` 默认允许所有来源(`*`),建议限制为实际前端域名 | +| P2-14 | `_call_dbv2` 返回值访问前未统一检查 success | ❌ 未修复 | `dnfs.py:387,444,519,537` | 多处直接访问 `notes['data']` 而不先确认 `success==True`,db 业务错误时可能 KeyError | +| P2-15 | `pid` 参数判断逻辑不严谨 | ❌ 未修复 | `dnfs.py:432` | `if not pid: pid = 0` 将空字符串/None/0 等非法输入默默转为 0,掩盖参数错误 | +| P2-16 | 遗留注释代码 | ❌ 未修复 | `dnfs.py:17,491-507,636-648,677-711,715-718` | 大量注释掉的旧版 SQLAlchemy 直连逻辑与已废弃接口,影响可读性 | -### 短期(72小时) -- [x] Token 过期严格校验 ✅ 已完成(gateway 统一处理) -- [x] is_admin 二次验证 ✅ 已完成(gateway 服务端判定) +## 5. 历史审计记录(2026-08-01 / 2026-08-03,作溯源) + +原始审计仅覆盖 5 项发现,其验证结论如下(与第 4 节对应编号映射): + +| 原编号 | 原问题 | 状态 | 映射至本章 | +|--------|--------|------|-----------| +| P0-1 | Token 无 RSA 签名验证 | ✅ 已修复 | 已合入代码注释 (dnfs.py) | +| P1-1 | Token 过期校验不严谨 | ✅ 已修复 | 已合入代码注释 (dnfs.py) | +| P1-2 | is_admin 权限完全依赖 Token 字段 | ✅ 已修复 | 已并入 P0-1(gateway 服务端基于 Admin 白名单判定) | +| P2-1 | AI 审核 fail-closed 实现需验证 | ❌ 未修复(类型不一致) | P0-2 | +| P2-2 | 节点删除无确认机制 / 无速率限制 | ❌ 未修复 | P0-4(及一般加固项) | + +原始审计的「新增安全机制」表中以下项经代码实证仍然成立: +- Token 验签委托 gateway(`dnfs.py:312-351`);fail-closed 503(`dnfs.py:336-339`) +- 配置白名单合并(`dnfs.py:55-63`);配置完整性校验(`dnfs.py:100-112`) +- 特殊节点审核脱敏(`dnfs.py:273-286`) +- 统一异常处理 `_handle_exp`(`dnfs.py:294-305`) +- 异步日志 fail-safe(`log_client.py:66-72,147-148`) + +## 6. db 错误处理一致性矩阵(Python dnfs) + +Python dnfs 多处直接访问 `notes['data']` 而未先确认 `success`,与 dnfs2 的「db 业务错误统一返回 500」形成对比: + +| 位置 | 查询目的 | 行为 | 风险 | +|------|---------|------|------| +| `add_note` 父节点检查 | L387 `notes['data']` | 未查 success | success=False 时 KeyError | +| `get_notes` | L444 `notes['data']` | 未查 success | success=False 时 KeyError | +| `delete_note` 子节点检查 | L519 `notes['data']` | 未查 success | success=False 时 KeyError | +| `delete_note` 所有权检查 | L537 `notes['data']` | 未查 success | success=False 时 KeyError | + +> dnfs2 已将全部 select 的 db 业务错误统一返回 500(见 `dnfs2_SECURITY_AUDIT_20260808.md` §6 矩阵),此项在 dnfs2 已闭合。 + +## 7. 与 dnfs2 的修复对照 + +| 本章编号 | Python dnfs 状态 | dnfs2 状态 | +|---------|-----------------|-----------| +| P0-2 | ⚠️ 基本修复 | ✅ 已修复(返回类型干净) | +| P0-4 | ❌ 未修复 | ⚠️ success 检查已修,TOCTOU 残余(L-2) | +| P1-6 | ❌ 未修复 | ✅ 已修复(`setMaxBodySize(10MB)`) | +| P1-7 | ❌ 未修复 | ✅ 已修复(日志复用主配置) | +| P1-8 | ❌ 未修复 | ✅ 已修复(pid≠0 校验父节点) | +| P1-9 | ❌ 未修复 | ✅ 已修复(`retry_on_refused=true`) | +| P1-11 | ❌ 未修复 | ✅ 已修复(`get_int()` 强类型) | +| P2-12 | ❌ 未修复 | ✅ 已修复(清除 IP/用户名泄露) | +| P2-13 | ❌ 未修复 | 记录(前端架构限制 L-7) | +| P2-14 | ❌ 未修复 | ✅ 已修复(`_call_db` 统一查 success) | +| P2-15 | ❌ 未修复 | ✅ 已修复(严格类型 + 负数校验) | +| P2-16 | ❌ 未修复 | ✅ 已清理 | + +**结论**:dnfs2 基本闭合了 Python dnfs 的全部 P0/P1 及大部分 P2;Python dnfs 的未修复项无需在 Python 版继续投入,统一以 dnfs2 为准。 + +## 8. 修复路线图(仅针对需要继续维护 Python dnfs 的极端场景) + +### 立即(如被迫启用 Python dnfs) +- [ ] P0-4:Notes 表 `pid` 加 `FOREIGN KEY ... REFERENCES Notes(id) ON DELETE RESTRICT`;应用层先查 `success` 再访问 `data` +- [ ] P1-6:`serve()` 配置 `max_request_body_size=10MB` +- [ ] P2-14:封装统一的 db 调用检查 `success` + +### 短期 +- [ ] P1-7:日志客户端复用 confs 主配置 +- [ ] P1-8:`update_note` 改 pid 前校验父节点存在 +- [ ] P1-9:`RpcClient` 启用 `retry_on_refused=True` +- [ ] P1-11:`note_id` 显式 `int()` 转换 + 校验 +- [ ] P2-12:错误消息去除 IP/用户名,仅记日志 ### 中期 -- [ ] 清理 `review_content` 返回类型不一致(bool vs dict) -- [ ] 节点操作速率限制 -- [ ] 节点删除二次确认机制 +- [ ] P2-13:CORS 限制为实际前端域名 +- [ ] P2-15:`pid` 参数严格校验(拒绝空串/None) +- [ ] P2-16:清理遗留注释代码 +- [ ] P0-2:清理 `review_content` 返回类型不一致 + +> **推荐替代方案**:直接弃用 Python dnfs,全部流量走 dnfs2(已在 `core/init.txt` 生效)。 From 4dd25486f830eb5330fa015a4007a6e0558ca7d2 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 18:17:03 +0000 Subject: [PATCH 012/196] =?UTF-8?q?feat(actpred):=20=E5=A4=9A=E5=B8=A7?= =?UTF-8?q?=E6=B3=A8=E6=84=8F=E5=8A=9B=E6=9E=B6=E6=9E=84=E6=94=B9=E9=80=A0?= =?UTF-8?q?=EF=BC=88Transformer=20=E6=9B=BF=E6=8D=A2=20concat=20=E8=9E=8D?= =?UTF-8?q?=E5=90=88=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A=E5=B8=A7=E5=9B=BE?= =?UTF-8?q?=E5=83=8F+=E5=8E=86=E5=8F=B2=E5=8A=A8=E4=BD=9C=E5=BA=8F?= =?UTF-8?q?=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actpred/README.md | 42 ++++++++---- actpred/config.py | 28 ++++++-- actpred/dataset.py | 22 +++++-- actpred/export_onnx.py | 15 +++-- actpred/model.py | 90 +++++++++++++------------- actpred/predict.py | 29 +++++++-- actpred/pretrain_mt.py | 36 ++++++----- actpred/test_actpred.py | 137 ++++++++++++++++++++++++++++++---------- actpred/train.py | 20 +++--- 9 files changed, 272 insertions(+), 147 deletions(-) diff --git a/actpred/README.md b/actpred/README.md index f46163d..0db7cba 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -1,19 +1,26 @@ # actpred — 动作预测模块 -输入摄像头图像 + 历史动作序列,预测下一个连续动作(回归)。 +输入多帧摄像头图像 + 历史动作序列,预测下一时间步的连续动作(回归)。 -纯神经网络实现(PyTorch),支持轻量 CNN 或 MobileNetV2 图像编码,MLP/GRU 动作历史编码。总参数量 ~140K(默认)至 ~2.3M。 +纯神经网络实现(PyTorch):轻量 CNN 或 MobileNetV2 逐帧编码,图像/动作 token 进轻量 +Transformer(pre-LN)自注意力融合,取 [PRED] token 回归下一步动作。 +默认配置总参数量 ~450K。 ## 架构 ``` -图像 (3×64×64) ──► CNN 编码器 ──► img_feat (128) - │ - ├─ concat ──► 融合头 ──► 预测动作 (action_dim) - │ -动作历史 (H×D) ──► MLP/GRU ──► act_feat (64) +F 帧图像 (F×3×64×64) ─► 共享 CNN 编码器 ─► F 个 token ─► proj → d_model ┐ + ├─► [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ────────┤ │ +可学习 [PRED] token ────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128) + │ + 取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim) ``` +token 序列长度固定为 F+H+1(默认 3+5+1=9),除 batch 外无动态 shape,对 NPU(如树莓派 +AI 扩展板)部署友好。 + ## 快速开始 ```bash @@ -23,8 +30,10 @@ cd actpred && bash _set.sh # 训练 bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 -# 推理(--action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) -bash _run.sh --model checkpoints/best_inference.pt --image frame.jpg \ +# 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧; +# --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) +bash _run.sh --model checkpoints/best_inference.pt \ + --images frame_t-2.jpg frame_t-1.jpg frame_t.jpg \ --action-history "45,0,45,0,45,0,45,0,90,90,10,200,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,20,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0" # 导出 ONNX(如安装了 onnxruntime,导出后自动验证一致性) @@ -39,7 +48,7 @@ python test_actpred.py ## 预训练(从 .mt 脚本,无需图像) -从 bot4cj 的 `.mt` 动作脚本中提取 `@Action` 矩阵,用零图像占位预训练动作动力学(history → next action), +从 bot4cj 的 `.mt` 动作脚本中提取 `@Action` 矩阵,用全零多帧图像占位预训练动作动力学(history → next action), 保存的 checkpoint 与正常训练格式完全兼容,可直接用于推理或作为微调起点。 ```bash @@ -74,7 +83,8 @@ data/ ... ``` -每个 timestep 的样本:当前图像 + 前 `history_len` 步动作 → 当前步动作。 +每个 timestep 的样本:帧 `[t-frame_len+1, t]`(旧→新)+ 前 `history_len` 步动作 → 当前步动作。 +采样起点为 `max(history_len, frame_len-1)`。 ### 数据采集 @@ -96,8 +106,11 @@ recorder.flush() | `--image-size` | 64 | 图像缩放尺寸 | | `--action-dim` | 18 | 动作向量维度(对齐 bot4cj 18 列:10 舵机 + 2 云台 + 扬声器频率 + 延时 + 4 预留)| | `--history-len` | 5 | 历史动作窗口长度 | +| `--frame-len` | 3 | 输入图像帧数(含当前帧,旧帧在前)| | `--backbone` | simple | CNN 类型 (simple/mobilenet) | -| `--history-encoder` | mlp | 历史编码器 (mlp/gru) | +| `--attn-dim` | 128 | 注意力 token 维度 d_model(须能被 attn-heads 整除)| +| `--attn-heads` | 4 | 注意力头数 | +| `--attn-layers` | 2 | TransformerEncoder 层数 | | `--epochs` | 100 | 训练轮数 | | `--lr` | 0.001 | 学习率 | | `--batch-size` | 32 | 批次大小 | @@ -124,7 +137,8 @@ recorder.flush() from predict import ActionPredictorInference predictor = ActionPredictorInference("checkpoints/best_inference.pt") -print(predictor.action_dim, predictor.history_len) # 公共属性 -action = predictor.predict(image_rgb, action_history) # np.ndarray (action_dim,) +print(predictor.action_dim, predictor.history_len, predictor.frame_len) # 公共属性 +# images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧;冷启动可重复同一帧) +action = predictor.predict(images, action_history) # np.ndarray (action_dim,) # action_history 形状须为 (history_len, action_dim) ``` diff --git a/actpred/config.py b/actpred/config.py index f6e1e98..395aa54 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -8,6 +8,7 @@ class DataConfig: image_size: int = 64 action_dim: int = 18 history_len: int = 5 + frame_len: int = 3 data_dir: str = "./data" normalize_actions: bool = False action_stats_path: Optional[str] = None @@ -17,10 +18,11 @@ class DataConfig: class ModelConfig: cnn_backbone: str = "simple" cnn_feature_dim: int = 128 - action_hidden_dim: int = 64 fused_hidden_dim: int = 128 dropout: float = 0.1 - history_encoder: str = "mlp" + attn_dim: int = 128 + attn_heads: int = 4 + attn_layers: int = 2 @dataclass @@ -60,15 +62,17 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser: parser.add_argument("--image-size", type=int, default=None) parser.add_argument("--action-dim", type=int, default=None) parser.add_argument("--history-len", type=int, default=None) + parser.add_argument("--frame-len", type=int, default=None) parser.add_argument("--data-dir", type=str, default=None) parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None) parser.add_argument("--action-stats-path", type=str, default=None) parser.add_argument("--backbone", type=str, default=None, choices=["simple", "mobilenet"]) parser.add_argument("--cnn-feature-dim", type=int, default=None) - parser.add_argument("--action-hidden-dim", type=int, default=None) parser.add_argument("--fused-hidden-dim", type=int, default=None) parser.add_argument("--dropout", type=float, default=None) - parser.add_argument("--history-encoder", type=str, default=None, choices=["mlp", "gru"]) + parser.add_argument("--attn-dim", type=int, default=None) + parser.add_argument("--attn-heads", type=int, default=None) + parser.add_argument("--attn-layers", type=int, default=None) parser.add_argument("--batch-size", type=int, default=None) parser.add_argument("--epochs", type=int, default=None) parser.add_argument("--lr", type=float, default=None) @@ -95,11 +99,14 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig, checks = [ ("image_size", data_cfg.image_size, 1, 1024, int), ("history_len", data_cfg.history_len, 1, 1024, int), + ("frame_len", data_cfg.frame_len, 1, 1024, int), ("action_dim", data_cfg.action_dim, 1, 1024, int), ("cnn_feature_dim", model_cfg.cnn_feature_dim, 1, 4096, int), - ("action_hidden_dim", model_cfg.action_hidden_dim, 1, 4096, int), ("fused_hidden_dim", model_cfg.fused_hidden_dim, 1, 4096, int), ("dropout", model_cfg.dropout, 0.0, 1.0, float), + ("attn_dim", model_cfg.attn_dim, 1, 4096, int), + ("attn_heads", model_cfg.attn_heads, 1, 64, int), + ("attn_layers", model_cfg.attn_layers, 1, 64, int), ] for name, val, lo, hi, typ in checks: if typ is int: @@ -110,6 +117,11 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig, raise ValueError( f"checkpoint config {name}={val} 超出合理范围 [{lo}, {hi}]") + if model_cfg.attn_dim % model_cfg.attn_heads != 0: + raise ValueError( + f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads=" + f"{model_cfg.attn_heads} 整除") + if train_cfg is not None: t_checks = [ ("batch_size", train_cfg.batch_size, 1, 65536, int), @@ -162,15 +174,17 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config: "image_size": ("data", "image_size"), "action_dim": ("data", "action_dim"), "history_len": ("data", "history_len"), + "frame_len": ("data", "frame_len"), "data_dir": ("data", "data_dir"), "normalize_actions": ("data", "normalize_actions"), "action_stats_path": ("data", "action_stats_path"), "backbone": ("model", "cnn_backbone"), "cnn_feature_dim": ("model", "cnn_feature_dim"), - "action_hidden_dim": ("model", "action_hidden_dim"), "fused_hidden_dim": ("model", "fused_hidden_dim"), "dropout": ("model", "dropout"), - "history_encoder": ("model", "history_encoder"), + "attn_dim": ("model", "attn_dim"), + "attn_heads": ("model", "attn_heads"), + "attn_layers": ("model", "attn_layers"), "batch_size": ("train", "batch_size"), "epochs": ("train", "epochs"), "lr": ("train", "lr"), diff --git a/actpred/dataset.py b/actpred/dataset.py index 54a14ae..fd4e924 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -17,10 +17,12 @@ def _natural_key(p): class ActionDataset(Dataset): def __init__(self, data_dir: str, history_len: int, action_dim: int, image_size: int = 64, normalize_actions: bool = False, - action_stats_path: str = None, episode_indices: list = None): + action_stats_path: str = None, episode_indices: list = None, + frame_len: int = 3): self.history_len = history_len self.action_dim = action_dim self.image_size = image_size + self.frame_len = frame_len self.transform = transforms.Compose([ transforms.Resize((image_size, image_size)), @@ -51,8 +53,11 @@ class ActionDataset(Dataset): print(f"WARNING: {ep_dir} 动作帧数 {n_frames} 与图像数 {n_images} 不一致," f"按较少者({min(n_frames, n_images)})使用,请检查录制完整性") n = min(n_frames, n_images) - for t in range(history_len, n): - self.samples.append((image_files[t], actions[t - history_len:t], actions[t])) + # 每个样本:帧 [t-frame_len+1, t](旧→新)+ 动作历史 [t-history_len, t) → 动作 t + start = max(history_len, frame_len - 1) + for t in range(start, n): + self.samples.append((image_files[t - frame_len + 1: t + 1], + actions[t - history_len:t], actions[t])) self._action_mean = None self._action_std = None @@ -77,15 +82,18 @@ class ActionDataset(Dataset): return len(self.samples) def __getitem__(self, idx): - image_path, action_history, target = self.samples[idx] - image = Image.open(image_path).convert("RGB") - image = self.transform(image) + image_paths, action_history, target = self.samples[idx] + frames = [] + for p in image_paths: + image = Image.open(p).convert("RGB") + frames.append(self.transform(image)) + images = torch.stack(frames) # (F, 3, S, S) action_history = torch.tensor(action_history, dtype=torch.float32) target = torch.tensor(target, dtype=torch.float32) if self._action_mean is not None: action_history = (action_history - self._action_mean) / self._action_std target = (target - self._action_mean) / self._action_std - return {"image": image, "action_history": action_history, "target": target} + return {"images": images, "action_history": action_history, "target": target} def get_action_stats(self): if self._action_mean is not None: diff --git a/actpred/export_onnx.py b/actpred/export_onnx.py index ac367fd..06befac 100644 --- a/actpred/export_onnx.py +++ b/actpred/export_onnx.py @@ -19,16 +19,17 @@ def main(): model.load_state_dict(checkpoint["model_state_dict"]) model.eval() - dummy_image = torch.randn(1, 3, data_cfg.image_size, data_cfg.image_size) + dummy_images = torch.randn( + 1, data_cfg.frame_len, 3, data_cfg.image_size, data_cfg.image_size) dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim) torch.onnx.export( - model, (dummy_image, dummy_history), + model, (dummy_images, dummy_history), args.output, - input_names=["image", "action_history"], + input_names=["images", "action_history"], output_names=["predicted_action"], dynamic_axes={ - "image": {0: "batch"}, + "images": {0: "batch"}, "action_history": {0: "batch"}, "predicted_action": {0: "batch"}, }, @@ -41,11 +42,11 @@ def main(): import onnxruntime as ort import numpy as np sess = ort.InferenceSession(args.output) - img_np = dummy_image.numpy() + img_np = dummy_images.numpy() hist_np = dummy_history.numpy() - ort_out = sess.run(None, {"image": img_np, "action_history": hist_np})[0] + ort_out = sess.run(None, {"images": img_np, "action_history": hist_np})[0] with torch.no_grad(): - torch_out = model(dummy_image, dummy_history).numpy() + torch_out = model(dummy_images, dummy_history).numpy() max_diff = np.abs(ort_out - torch_out).max() print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})") except ImportError: diff --git a/actpred/model.py b/actpred/model.py index 30378c4..7a6d6fc 100644 --- a/actpred/model.py +++ b/actpred/model.py @@ -53,34 +53,7 @@ class MobileNetEncoder(nn.Module): return self.proj(x) -class MLPHistoryEncoder(nn.Module): - def __init__(self, input_dim: int, hidden_dim: int = 64, dropout: float = 0.1): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_dim, hidden_dim), - nn.ReLU(inplace=True), - nn.Dropout(dropout), - nn.Linear(hidden_dim, hidden_dim), - nn.ReLU(inplace=True), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.net(x) - - -class GRUHistoryEncoder(nn.Module): - def __init__(self, input_dim: int, hidden_dim: int = 64, dropout: float = 0.1): - super().__init__() - self.gru = nn.GRU(input_size=input_dim, hidden_size=hidden_dim, - num_layers=1, batch_first=True) - self.dropout = nn.Dropout(dropout) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - _, h = self.gru(x) - return self.dropout(h.squeeze(0)) - - -class FusionHead(nn.Module): +class PredictionHead(nn.Module): def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, dropout: float = 0.1): super().__init__() self.net = nn.Sequential( @@ -97,6 +70,13 @@ class FusionHead(nn.Module): class ActionPredictor(nn.Module): + """多帧图像 + 历史动作的 Transformer 注意力融合预测器 + + token 序列:[img×F, act×H, PRED],长度固定为 F+H+1, + 取 PRED 位置输出进预测头回归下一时间步动作。 + 除 batch 维外全部维度静态,保证 ONNX/NPU 部署友好。 + """ + def __init__(self, model_cfg: ModelConfig, data_cfg: DataConfig, pretrained: bool = True): super().__init__() @@ -106,26 +86,44 @@ class ActionPredictor(nn.Module): else: self.image_encoder = SimpleCNN(model_cfg.cnn_feature_dim) - history_input_dim = data_cfg.history_len * data_cfg.action_dim - if model_cfg.history_encoder == "gru": - self.action_encoder = GRUHistoryEncoder( - data_cfg.action_dim, model_cfg.action_hidden_dim, model_cfg.dropout) - else: - self.action_encoder = MLPHistoryEncoder( - history_input_dim, model_cfg.action_hidden_dim, model_cfg.dropout) + d = model_cfg.attn_dim + self.img_proj = nn.Linear(model_cfg.cnn_feature_dim, d) + self.act_proj = nn.Linear(data_cfg.action_dim, d) - fused_input = model_cfg.cnn_feature_dim + model_cfg.action_hidden_dim - self.head = FusionHead( - fused_input, model_cfg.fused_hidden_dim, data_cfg.action_dim, model_cfg.dropout) + seq_len = data_cfg.frame_len + data_cfg.history_len + 1 + self.pred_token = nn.Parameter(torch.zeros(1, 1, d)) + self.pos_embed = nn.Parameter(torch.zeros(1, seq_len, d)) + nn.init.trunc_normal_(self.pred_token, std=0.02) + nn.init.trunc_normal_(self.pos_embed, std=0.02) - self._use_gru = model_cfg.history_encoder == "gru" + encoder_layer = nn.TransformerEncoderLayer( + d_model=d, nhead=model_cfg.attn_heads, + dim_feedforward=d * 2, dropout=model_cfg.dropout, + batch_first=True, norm_first=True) + self.encoder = nn.TransformerEncoder( + encoder_layer, num_layers=model_cfg.attn_layers, + enable_nested_tensor=False) + + self.head = PredictionHead( + d, model_cfg.fused_hidden_dim, data_cfg.action_dim, model_cfg.dropout) + + self._frame_len = data_cfg.frame_len self._history_len = data_cfg.history_len self._action_dim = data_cfg.action_dim + self._image_size = data_cfg.image_size + self._cnn_feature_dim = model_cfg.cnn_feature_dim - def forward(self, image: torch.Tensor, action_history: torch.Tensor) -> torch.Tensor: - img_feat = self.image_encoder(image) - if self._use_gru: - act_feat = self.action_encoder(action_history) - else: - act_feat = self.action_encoder(action_history.flatten(1)) - return self.head(torch.cat([img_feat, act_feat], dim=-1)) + def forward(self, images: torch.Tensor, action_history: torch.Tensor) -> torch.Tensor: + # images: (B, F, 3, S, S) action_history: (B, H, D) + b = images.size(0) + frames = images.reshape( + b * self._frame_len, 3, self._image_size, self._image_size) + img_feat = self.image_encoder(frames).reshape( + b, self._frame_len, self._cnn_feature_dim) + img_tokens = self.img_proj(img_feat) + act_tokens = self.act_proj(action_history) + pred_token = self.pred_token.expand(b, -1, -1) + tokens = torch.cat([img_tokens, act_tokens, pred_token], dim=1) + tokens = tokens + self.pos_embed + out = self.encoder(tokens) + return self.head(out[:, -1]) diff --git a/actpred/predict.py b/actpred/predict.py index 165681d..c9eb971 100644 --- a/actpred/predict.py +++ b/actpred/predict.py @@ -28,6 +28,7 @@ class ActionPredictorInference: self.action_dim = self._data_cfg.action_dim self.history_len = self._data_cfg.history_len + self.frame_len = self._data_cfg.frame_len self._transform = transforms.Compose([ transforms.Resize((self._data_cfg.image_size, self._data_cfg.image_size)), @@ -61,13 +62,23 @@ class ActionPredictorInference: self._action_std = stats[1].astype(np.float32) @torch.no_grad() - def predict(self, image: np.ndarray, action_history: np.ndarray) -> np.ndarray: + def predict(self, images, action_history: np.ndarray) -> np.ndarray: + """images: 长度为 frame_len 的 RGB 图像序列(旧→新,末帧为当前帧)""" + if not isinstance(images, (list, tuple)): + images = [images] + if len(images) != self._data_cfg.frame_len: + raise ValueError( + f"images 应为 {self._data_cfg.frame_len} 帧,实际为 {len(images)} 帧。" + f"冷启动时可重复传入同一帧。") expected_shape = (self._data_cfg.history_len, self._data_cfg.action_dim) if action_history.shape != expected_shape: raise ValueError( f"action_history 形状应为 {expected_shape},实际为 {action_history.shape}") - img = Image.fromarray(image) if isinstance(image, np.ndarray) else image - img_tensor = self._transform(img).unsqueeze(0).to(self._device) + frames = [] + for image in images: + img = Image.fromarray(image) if isinstance(image, np.ndarray) else image + frames.append(self._transform(img)) + img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S) history = torch.tensor(action_history, dtype=torch.float32) if self._action_mean is not None: history = (history - self._action_mean) / self._action_std @@ -93,7 +104,9 @@ def parse_action_history(s: str, action_dim: int) -> np.ndarray: def main(): parser = argparse.ArgumentParser(description="actpred inference") parser.add_argument("--model", required=True, help="checkpoint path") - parser.add_argument("--image", required=True, help="image file path") + parser.add_argument("--images", required=True, nargs="+", + help="按时间顺序(旧→新)传入 frame_len 个图像路径;" + "冷启动时可重复传同一帧路径") parser.add_argument("--action-history", required=True, help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。" "e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)") @@ -106,9 +119,13 @@ def main(): predictor = ActionPredictorInference(args.model, args.device) action_dim = args.action_dim or predictor.action_dim - image = Image.open(args.image).convert("RGB") + if len(args.images) != predictor.frame_len: + raise SystemExit( + f"--images 需传入 {predictor.frame_len} 个路径(旧→新)," + f"实际为 {len(args.images)} 个。冷启动时可重复传同一帧路径。") + images = [np.array(Image.open(p).convert("RGB")) for p in args.images] history = parse_action_history(args.action_history, action_dim) - predicted = predictor.predict(np.array(image), history) + predicted = predictor.predict(images, history) if args.output_format == "json": print(json.dumps({"predicted_action": predicted.tolist()}, indent=2)) diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index dbeb29e..ed18375 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -76,24 +76,25 @@ class MTActionDataset(Dataset): } -def pretrain_one_epoch(model, loader, optimizer, device, image_size, +def pretrain_one_epoch(model, loader, optimizer, device, image_size, frame_len, max_grad_norm=None, noise_std=0.0): model.train() total_loss = 0.0 count = 0 - zero_image = None + zero_images = None for batch in tqdm(loader, desc=" pretrain", leave=False): history = batch["action_history"].to(device) target = batch["target"].to(device) if noise_std > 0: history = history + torch.randn_like(history) * noise_std target = target + torch.randn_like(target) * noise_std - if zero_image is None or zero_image.size(0) != history.size(0): - zero_image = torch.zeros( - history.size(0), 3, image_size, image_size, device=device + if zero_images is None or zero_images.size(0) != history.size(0): + zero_images = torch.zeros( + history.size(0), frame_len, 3, image_size, image_size, + device=device, ) optimizer.zero_grad() - pred = model(zero_image, history) + pred = model(zero_images, history) loss = torch.nn.functional.mse_loss(pred, target) loss.backward() if max_grad_norm: @@ -105,19 +106,20 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_size, @torch.no_grad() -def validate(model, loader, device, image_size): +def validate(model, loader, device, image_size, frame_len): model.eval() total_loss = 0.0 count = 0 - zero_image = None + zero_images = None for batch in tqdm(loader, desc=" val ", leave=False): history = batch["action_history"].to(device) target = batch["target"].to(device) - if zero_image is None or zero_image.size(0) != history.size(0): - zero_image = torch.zeros( - history.size(0), 3, image_size, image_size, device=device + if zero_images is None or zero_images.size(0) != history.size(0): + zero_images = torch.zeros( + history.size(0), frame_len, 3, image_size, image_size, + device=device, ) - pred = model(zero_image, history) + pred = model(zero_images, history) loss = torch.nn.functional.mse_loss(pred, target) total_loss += loss.item() * history.size(0) count += history.size(0) @@ -198,8 +200,10 @@ def main(): print(f"Model params: {n_params:,}") print(f"Config: action_dim={cfg.data.action_dim}, " f"history_len={cfg.data.history_len}, " + f"frame_len={cfg.data.frame_len}, " f"backbone={cfg.model.cnn_backbone}, " - f"encoder={cfg.model.history_encoder}") + f"attn={cfg.model.attn_layers}x{cfg.model.attn_heads}h" + f"@{cfg.model.attn_dim}") print(f"Pre-training: noise_std={args.noise_std}") optimizer = torch.optim.AdamW( @@ -222,9 +226,11 @@ def main(): f"lr={optimizer.param_groups[0]['lr']:.6f}") train_loss = pretrain_one_epoch( model, train_loader, optimizer, device, - cfg.data.image_size, cfg.train.max_grad_norm, args.noise_std, + cfg.data.image_size, cfg.data.frame_len, + cfg.train.max_grad_norm, args.noise_std, ) - val_loss = validate(model, val_loader, device, cfg.data.image_size) + val_loss = validate(model, val_loader, device, + cfg.data.image_size, cfg.data.frame_len) scheduler.step() print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}") diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index 370eb65..a92a6ad 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -79,45 +79,57 @@ class TestParseActionHistory(unittest.TestCase): class TestActionPredictor(unittest.TestCase): - """测试模型前向传播""" + """测试多帧注意力模型前向传播""" - def test_forward_simple_mlp(self): - data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp") + def test_forward_multi_frame(self): + data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5, frame_len=3) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() - image = torch.randn(2, 3, 64, 64) + images = torch.randn(2, 3, 3, 64, 64) history = torch.randn(2, 5, 18) with torch.no_grad(): - out = model(image, history) + out = model(images, history) self.assertEqual(out.shape, (2, 18)) - def test_forward_simple_gru(self): - data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="gru") + def test_forward_single_frame_degenerate(self): + """frame_len=1 退化为单帧输入也应正常前向""" + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=1) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() - image = torch.randn(2, 3, 64, 64) - history = torch.randn(2, 5, 18) + images = torch.randn(2, 1, 3, 32, 32) + history = torch.randn(2, 3, 4) with torch.no_grad(): - out = model(image, history) - self.assertEqual(out.shape, (2, 18)) + out = model(images, history) + self.assertEqual(out.shape, (2, 4)) def test_forward_dynamic_batch(self): - data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp") + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=2) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() for batch_size in [1, 4, 8]: - image = torch.randn(batch_size, 3, 32, 32) + images = torch.randn(batch_size, 2, 3, 32, 32) history = torch.randn(batch_size, 3, 4) with torch.no_grad(): - out = model(image, history) + out = model(images, history) self.assertEqual(out.shape, (batch_size, 4)) + def test_custom_attn_config(self): + """自定义 attn_dim/heads/layers 应可构建并前向""" + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=2) + model_cfg = ModelConfig(attn_dim=64, attn_heads=2, attn_layers=1) + model = ActionPredictor(model_cfg, data_cfg) + model.eval() + + with torch.no_grad(): + out = model(torch.randn(1, 2, 3, 32, 32), torch.randn(1, 3, 4)) + self.assertEqual(out.shape, (1, 4)) + class TestDataRecorder(unittest.TestCase): """测试数据录制器""" @@ -169,17 +181,30 @@ class TestActionDataset(unittest.TestCase): def test_dataset_loading(self): with tempfile.TemporaryDirectory() as tmpdir: self._create_dummy_episode(Path(tmpdir), n_frames=10, action_dim=4) - ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32) - self.assertEqual(len(ds), 7) # 10 - 3 = 7 samples + ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32, + frame_len=3) + # 采样起点 max(history_len, frame_len-1) = 3 → 10 - 3 = 7 samples + self.assertEqual(len(ds), 7) sample = ds[0] - self.assertIn("image", sample) + self.assertIn("images", sample) self.assertIn("action_history", sample) self.assertIn("target", sample) - self.assertEqual(sample["image"].shape, (3, 32, 32)) + self.assertEqual(sample["images"].shape, (3, 3, 32, 32)) self.assertEqual(sample["action_history"].shape, (3, 4)) self.assertEqual(sample["target"].shape, (4,)) + def test_frame_len_gt_history_shifts_start(self): + """frame_len-1 > history_len 时采样起点由帧窗口决定""" + with tempfile.TemporaryDirectory() as tmpdir: + self._create_dummy_episode(Path(tmpdir), n_frames=10, action_dim=4) + ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32, + frame_len=6) + # 起点 max(3, 5) = 5 → 10 - 5 = 5 samples + self.assertEqual(len(ds), 5) + sample = ds[0] + self.assertEqual(sample["images"].shape, (6, 3, 32, 32)) + def test_episode_count(self): with tempfile.TemporaryDirectory() as tmpdir: self._create_dummy_episode(Path(tmpdir)) @@ -241,17 +266,24 @@ class TestConfig(unittest.TestCase): self.assertEqual(cfg.data.image_size, 64) self.assertEqual(cfg.data.action_dim, 18) self.assertEqual(cfg.data.history_len, 5) + self.assertEqual(cfg.data.frame_len, 3) self.assertEqual(cfg.model.cnn_backbone, "simple") - self.assertEqual(cfg.model.history_encoder, "mlp") + self.assertEqual(cfg.model.attn_dim, 128) + self.assertEqual(cfg.model.attn_heads, 4) + self.assertEqual(cfg.model.attn_layers, 2) self.assertEqual(cfg.train.epochs, 100) def test_apply_overrides(self): parser = build_argparser("test") - args = parser.parse_args(["--epochs", "50", "--lr", "0.01", "--backbone", "mobilenet"]) + args = parser.parse_args(["--epochs", "50", "--lr", "0.01", + "--backbone", "mobilenet", + "--frame-len", "4", "--attn-dim", "64"]) cfg = apply_overrides(Config(), args) self.assertEqual(cfg.train.epochs, 50) self.assertEqual(cfg.train.lr, 0.01) self.assertEqual(cfg.model.cnn_backbone, "mobilenet") + self.assertEqual(cfg.data.frame_len, 4) + self.assertEqual(cfg.model.attn_dim, 64) def test_normalize_actions_flag(self): """--normalize-actions 和 --no-normalize-actions 都应可用""" @@ -421,18 +453,23 @@ class TestValidateConfigValues(unittest.TestCase): def test_out_of_range_or_non_int_raises(self): for field, value in [("image_size", 99999), ("image_size", 0), - ("history_len", -1), ("action_dim", "18")]: + ("history_len", -1), ("frame_len", 0), + ("frame_len", 99999), ("action_dim", "18")]: cfg = Config() setattr(cfg.data, field, value) with self.assertRaises(ValueError, msg=f"{field}={value}"): validate_config_values(cfg.data, cfg.model) def test_model_field_out_of_range_raises(self): - """model 字段越界(fused_hidden_dim 超限、dropout 越界)应抛 ValueError""" + """model 字段越界(fused_hidden_dim 超限、dropout 越界、attn_* 越界)应抛 ValueError""" for field, value in [("fused_hidden_dim", 10 ** 9), ("fused_hidden_dim", 0), ("cnn_feature_dim", 99999), - ("action_hidden_dim", -1), + ("attn_dim", 0), + ("attn_dim", 99999), + ("attn_heads", 0), + ("attn_heads", 128), + ("attn_layers", 0), ("dropout", 1.5), ("dropout", -0.1)]: cfg = Config() @@ -440,6 +477,14 @@ class TestValidateConfigValues(unittest.TestCase): with self.assertRaises(ValueError, msg=f"{field}={value}"): validate_config_values(cfg.data, cfg.model) + def test_attn_dim_not_divisible_by_heads_raises(self): + """attn_dim 不能被 attn_heads 整除应抛 ValueError""" + cfg = Config() + cfg.model.attn_dim = 130 + cfg.model.attn_heads = 4 + with self.assertRaisesRegex(ValueError, "整除"): + validate_config_values(cfg.data, cfg.model) + def test_train_field_out_of_range_raises(self): """train 字段越界(lr 为负、val_split>1 等)应抛 ValueError""" for field, value in [("lr", -1.0), ("lr", 0.0), @@ -474,6 +519,15 @@ class TestParseCheckpointConfig(unittest.TestCase): with self.assertRaisesRegex(ValueError, "evil_extra_key"): parse_checkpoint_config(d) + def test_legacy_checkpoint_config_rejected(self): + """旧架构 checkpoint(含 history_encoder/action_hidden_dim)应拒载""" + cfg = Config() + d = cfg.to_dict() + d["model"]["history_encoder"] = "mlp" + d["model"]["action_hidden_dim"] = 64 + with self.assertRaisesRegex(ValueError, "history_encoder"): + parse_checkpoint_config(d) + def test_missing_section_raises(self): cfg = Config() d = cfg.to_dict() @@ -519,11 +573,13 @@ class TestActionPredictorInference(unittest.TestCase): """测试推理封装端到端:checkpoint 保存 → 加载 → 推理""" @staticmethod - def _make_checkpoint(tmpdir, normalize_actions=False, image_size=32): + def _make_checkpoint(tmpdir, normalize_actions=False, image_size=32, + frame_len=3): cfg = Config() cfg.data.image_size = image_size cfg.data.action_dim = 4 cfg.data.history_len = 3 + cfg.data.frame_len = frame_len cfg.data.normalize_actions = normalize_actions model = ActionPredictor(cfg.model, cfg.data) model.eval() @@ -533,17 +589,19 @@ class TestActionPredictorInference(unittest.TestCase): return path, model, cfg def test_end_to_end_predict(self): - """加载 checkpoint 后推理输出形状正确、数值有限且可复现""" + """加载 checkpoint 后多帧推理输出形状正确、数值有限且可复现""" with tempfile.TemporaryDirectory() as tmpdir: path, _, _ = self._make_checkpoint(tmpdir) predictor = ActionPredictorInference(path, device="cpu") self.assertEqual(predictor.action_dim, 4) self.assertEqual(predictor.history_len, 3) + self.assertEqual(predictor.frame_len, 3) - image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) + images = [np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) + for _ in range(3)] history = np.random.randn(3, 4).astype(np.float32) - pred1 = predictor.predict(image, history) - pred2 = predictor.predict(image, history) + pred1 = predictor.predict(images, history) + pred2 = predictor.predict(images, history) self.assertEqual(pred1.shape, (4,)) self.assertTrue(np.all(np.isfinite(pred1))) np.testing.assert_array_equal(pred1, pred2) # eval 模式确定性 @@ -563,9 +621,18 @@ class TestActionPredictorInference(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: path, _, _ = self._make_checkpoint(tmpdir) predictor = ActionPredictorInference(path, device="cpu") - image = np.zeros((32, 32, 3), dtype=np.uint8) + images = [np.zeros((32, 32, 3), dtype=np.uint8)] * 3 with self.assertRaisesRegex(ValueError, "action_history"): - predictor.predict(image, np.zeros((2, 4), dtype=np.float32)) + predictor.predict(images, np.zeros((2, 4), dtype=np.float32)) + + def test_wrong_frame_count_raises(self): + """传入帧数与 frame_len 不一致应抛 ValueError""" + with tempfile.TemporaryDirectory() as tmpdir: + path, _, _ = self._make_checkpoint(tmpdir) + predictor = ActionPredictorInference(path, device="cpu") + images = [np.zeros((32, 32, 3), dtype=np.uint8)] * 2 + with self.assertRaisesRegex(ValueError, "帧"): + predictor.predict(images, np.zeros((3, 4), dtype=np.float32)) def test_missing_action_stats_raises(self): """启用归一化但缺 action_stats.npy 应抛 FileNotFoundError""" @@ -594,9 +661,9 @@ class TestActionPredictorInference(unittest.TestCase): image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) history = np.random.randn(3, 4).astype(np.float32) pred_raw = ActionPredictorInference( - raw_path, device="cpu").predict(image, history) + raw_path, device="cpu").predict([image] * 3, history) pred_norm = ActionPredictorInference( - norm_path, device="cpu").predict(image, history) + norm_path, device="cpu").predict([image] * 3, history) np.testing.assert_allclose(pred_norm, pred_raw, rtol=1e-5, atol=1e-6) def test_stats_shape_mismatch_raises(self): diff --git a/actpred/train.py b/actpred/train.py index b8d61aa..50b8ffe 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -25,18 +25,18 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non total_loss = 0.0 count = 0 for batch in tqdm(loader, desc=" train", leave=False): - image = batch["image"].to(device) + images = batch["images"].to(device) history = batch["action_history"].to(device) target = batch["target"].to(device) optimizer.zero_grad() - pred = model(image, history) + pred = model(images, history) loss = torch.nn.functional.mse_loss(pred, target) loss.backward() if max_grad_norm: torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() - total_loss += loss.item() * image.size(0) - count += image.size(0) + total_loss += loss.item() * images.size(0) + count += images.size(0) return total_loss / max(count, 1) @@ -46,13 +46,13 @@ def validate(model, loader, device): total_loss = 0.0 count = 0 for batch in tqdm(loader, desc=" val ", leave=False): - image = batch["image"].to(device) + images = batch["images"].to(device) history = batch["action_history"].to(device) target = batch["target"].to(device) - pred = model(image, history) + pred = model(images, history) loss = torch.nn.functional.mse_loss(pred, target) - total_loss += loss.item() * image.size(0) - count += image.size(0) + total_loss += loss.item() * images.size(0) + count += images.size(0) if count == 0: return float("inf") # 空验证集不参与比较,避免假 best return total_loss / count @@ -83,7 +83,7 @@ def main(): train_ds = ActionDataset( cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_size, cfg.data.normalize_actions, - cfg.data.action_stats_path, train_indices) + cfg.data.action_stats_path, train_indices, cfg.data.frame_len) if cfg.data.normalize_actions and not cfg.data.action_stats_path: # 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信 stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy") @@ -93,7 +93,7 @@ def main(): val_ds = ActionDataset( cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_size, cfg.data.normalize_actions, - cfg.data.action_stats_path, val_indices) + cfg.data.action_stats_path, val_indices, cfg.data.frame_len) print(f"Samples: {len(train_ds)} train, {len(val_ds)} val") if len(train_ds) == 0: From 6089e7427d8f470e0c93d1d58eacb1b72ef828c6 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 18:17:06 +0000 Subject: [PATCH 013/196] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20actpred=20?= =?UTF-8?q?=E6=B3=A8=E6=84=8F=E5=8A=9B=E6=94=B9=E9=80=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E4=B8=8E=20bot4cj=20Python=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...actpred_attention_rewrite_test_20260809.md | 212 ++++++++++ .../bot4cj_python_migration_dev_doc.md | 400 ++++++++++++++++++ 2 files changed, 612 insertions(+) create mode 100644 docs/reports/actpred_attention_rewrite_test_20260809.md create mode 100644 docs/reports/bot4cj_python_migration_dev_doc.md diff --git a/docs/reports/actpred_attention_rewrite_test_20260809.md b/docs/reports/actpred_attention_rewrite_test_20260809.md new file mode 100644 index 0000000..f428cb8 --- /dev/null +++ b/docs/reports/actpred_attention_rewrite_test_20260809.md @@ -0,0 +1,212 @@ +# actpred 多帧注意力架构改造测试报告 + +| 项目 | 值 | +|------|-----| +| 日期 | 2026-08-09 | +| 模块 | actpred | +| 测试人 | qing | +| 环境 | Intel Xeon E3-1200 v2 (Ivy Bridge), 4 核, Linux 24.04, Python 3.x, PyTorch (CPU-only) | +| 关联文件 | `config.py`, `model.py`, `dataset.py`, `train.py`, `pretrain_mt.py`, `predict.py`, `export_onnx.py`, `test_actpred.py` | +| 前置报告 | [actpred_pretrain_test_20260808.md](actpred_pretrain_test_20260808.md) | + +--- + +## 一、改造背景与目标 + +原架构为"单帧 CNN + 历史动作 MLP/GRU + concat 融合",仅支持单帧图像输入。 +本次改造目标: + +1. 引入注意力机制,支持**多帧图像 + 历史动作序列**联合建模,预测下一时间步动作 +2. 保持 checkpoint 格式(`model_state_dict` + `config`)、CLI 工作流与 ONNX 导出链路不变 +3. 保持除 batch 维外全静态 shape,面向树莓派 AI 扩展板(NPU)部署友好 +4. 直接替换旧架构(用户决策):删除 MLP/GRU 编码器与 concat 路径,旧 checkpoint 作废 + +## 二、新架构 + +``` +F 帧图像 (F×3×64×64) ─► 共享 CNN 编码器 ─► F 个 token ─► proj → d_model ┐ + ├─► [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ──────────┤ │ +可学习 [PRED] token ─────────────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128) + │ + 取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim) +``` + +| 设计点 | 取值/说明 | +|--------|----------| +| token 序列 | `[img×F, act×H, PRED]`,长度固定 F+H+1(默认 3+5+1=9) | +| 帧序约定 | 旧→新,末帧为当前帧;冷启动可重复传同一帧 | +| 注意力 | `nn.TransformerEncoder`,pre-LN(norm_first),dim_feedforward=2×d_model | +| 位置编码 | 可学习 `pos_embed (1, F+H+1, d)`,trunc_normal 初始化 | +| 输出 | 取末位 PRED token 过 MLP 头(d → 128 → 64 → action_dim) | +| 静态性 | forward 中 reshape 仅 batch 维动态,其余编译期固定 | +| 参数量 | 409,042(旧架构 142,034,约 2.9 倍) | + +### 新增配置参数 + +| 参数 | 默认值 | 校验范围 | +|------|--------|---------| +| `--frame-len` | 3 | [1, 1024] | +| `--attn-dim` | 128 | [1, 4096],且须能被 attn-heads 整除 | +| `--attn-heads` | 4 | [1, 64] | +| `--attn-layers` | 2 | [1, 64] | + +删除参数:`--history-encoder`、`--action-hidden-dim`。 +旧 checkpoint 因 config 含未知键(`history_encoder`/`action_hidden_dim`)在加载时被 +`parse_checkpoint_config` 以明确 ValueError 拒载,符合"作废"预期(有专项测试覆盖)。 + +## 三、单元测试 + +``` +$ python test_actpred.py +Ran 56 tests in 5.959s +OK +``` + +**56 项测试全部通过**(安装 onnxscript/onnxruntime 后无跳过项)。本次新增/改写用例: + +| 用例 | 验证点 | +|------|--------| +| `test_forward_multi_frame` | (2,3,3,64,64) + (2,5,18) → (2,18) | +| `test_forward_single_frame_degenerate` | frame_len=1 退化单帧仍正常前向 | +| `test_forward_dynamic_batch` | batch 1/4/8 动态(frame_len=2) | +| `test_custom_attn_config` | attn_dim=64/heads=2/layers=1 可构建前向 | +| `test_attn_dim_not_divisible_by_heads_raises` | 130 % 4 ≠ 0 → ValueError | +| `test_dataset_loading` | 多帧样本 shape (3,3,32,32),batch key 改为 `images` | +| `test_frame_len_gt_history_shifts_start` | 采样起点 max(history_len, frame_len-1) 正确 | +| `test_legacy_checkpoint_config_rejected` | 旧架构 checkpoint 拒载 | +| `test_wrong_frame_count_raises` | 推理帧数不符 → ValueError | +| `test_end_to_end_predict` 等推理用例 | 多帧输入端到端、归一化等价性、确定性 | + +## 四、预训练测试 + +### 4.1 训练配置 + +| 参数 | 值 | +|------|-----| +| 数据来源 | `../bot4cj/scripts/*.mt`(13 文件) | +| train/val 划分 | **文件级**(11 训练文件 / 2 验证文件,杜绝滑动窗口泄漏) | +| 输入 | 全零多帧图像 (B, 3, 3, 64, 64) + action_history (B, 5, 18) | +| 优化器/调度 | AdamW (lr=0.001, wd=1e-4) + CosineAnnealingLR | +| 数据增强 | Gaussian noise (std=1.0) | +| batch_size / epochs / patience | 8 / 200 / 30 | +| 随机种子 | 42 | + +### 4.2 训练结果 + +| 指标 | 值 | +|------|-----| +| 最终轮数 | 65(早停,patience=30) | +| 最优 val_loss | **2476.75** | +| 产出 | `checkpoints/pretrain_best.pt`(含 optimizer)、`checkpoints/pretrain_best_inference.pt` | + +> **关于 val_loss 数值的说明**:本次 2476.75 与前次报告的 190.58 不可直接对比—— +> 前次数值系样本级滑动窗口划分导致的数据泄漏虚低值(已在 0808 审计中定性); +> 本次为文件级划分(验证集是训练中完全未见过的动作脚本),且模型更大(409K vs 142K) +> 而数据仅 13 个脚本,属"诚实的困难任务"数值。注意力架构的真实收益需在真实图像数据 +> 微调后评估。 + +## 五、推理链路验证 + +### 5.1 CLI 全链路 + +``` +$ bash _run.sh --model checkpoints/pretrain_best_inference.pt \ + --images /tmp/f0.jpg /tmp/f1.jpg /tmp/f2.jpg \ + --action-history "45,0,...(5 步 × 18 值)" +18.3430 10.1614 17.3654 ... (18 维输出,数值有限) +``` + +✅ 3 帧 + 5 步历史全链路输出正常。 + +### 5.2 错误场景 + +``` +$ ... --images /tmp/f0.jpg ... +--images 需传入 3 个路径(旧→新),实际为 1 个。冷启动时可重复传同一帧路径。 +``` + +✅ 帧数不符时报错信息明确,含冷启动指引。 + +## 六、ONNX 导出验证 + +``` +$ python export_onnx.py --model checkpoints/pretrain_best_inference.pt --output /tmp/actpred_attn.onnx +ONNX model exported to: /tmp/actpred_attn.onnx +ONNX 一致性验证通过(最大差异: 7.629395e-06) +``` + +| 项目 | 结果 | +|------|------| +| 导出 | ✅ 成功(opset 17,输入 `images (batch,3,3,64,64)` + `action_history (batch,5,18)`) | +| onnxruntime 一致性 | ✅ 最大差异 7.63e-06(< 1e-4 阈值) | +| 动态维 | 仅 batch,NPU 编译友好 | + +注:本次为环境补装了 `onnxscript` 与 `onnxruntime`(torch ≥ 2.12 ONNX 导出依赖)。 + +## 七、CPU 推理性能基准 + +测试方法:warmup 20 次后测 200 次取平均,输入随机 3 帧 (64,64,3) uint8 + (5,18) float32。 + +| 环节 | 本次(注意力,3 帧) | 前次(concat,单帧) | +|------|---------------------|---------------------| +| 参数量 | 409,042 | 142,034 | +| 端到端推理(含预处理) | **4.25 ms (235 FPS)** | 1.61 ms (622 FPS) | +| 纯前向 | 2.77 ms (360 FPS) | 1.09 ms (916 FPS) | +| image_encoder 占比 | 1.30 ms(47%,3 帧) | 0.82 ms(75%,1 帧) | + +评估: + +- 235 FPS 对 bot4cj 30 FPS 控制循环仍有 **7.8 倍余量**,实时性满足 +- 延迟增长主因:CNN 由 1 帧变 3 帧(约 3×0.43ms)+ Transformer 编码器(约 1.5ms) +- image_encoder 占比由 75% 降至 47%,注意力部分成为新的次要热点; + 部署到树莓派 AI 扩展板后两者均可被 NPU 加速(静态 shape 已保证) + +## 八、结论与下一步 + +### 8.1 测试结论 + +| 测试项 | 结果 | +|--------|------| +| 多帧注意力模型前向 | ✅ 通过(多帧/单帧退化/动态 batch/自定义 attn 配置) | +| 配置校验 | ✅ 通过(frame_len/attn_* 值域 + 整除校验 + 旧 checkpoint 拒载) | +| 多帧数据集采样 | ✅ 通过(采样起点与样本数断言) | +| 单元测试 | ✅ 56/56 通过 | +| 预训练 | ✅ 通过(65 epoch 早停,文件级划分无泄漏) | +| 推理 CLI | ✅ 通过(正常 + 错误场景) | +| ONNX 导出 | ✅ 通过(一致性 7.63e-06) | +| CPU 性能 | ✅ 通过(235 FPS,7.8 倍实时余量) | + +### 8.2 已知局限 + +1. **预训练图像分支未习得**:全零多帧占位下 image_encoder 与图像 token 的注意力权重 + 无有效梯度信号,真实收益依赖后续真实图像微调 +2. **数据量极少**:13 个 .mt 脚本对 409K 参数模型明显不足,val_loss 绝对值偏高属预期 +3. **旧 checkpoint 全部作废**:已删除并重训预训练 checkpoint;如有外部持有旧 + `best_inference.pt` 需同步告知重训 + +### 8.3 下一步建议 + +1. **采集真实多帧数据**:用 `DataRecorder` 录制 bot4cj 摄像头 episode,以本次预训练 + checkpoint 为起点微调(勿开 `--normalize-actions`) +2. **树莓派 AI 扩展板部署验证**:将 ONNX 模型走扩展板厂商工具链编译,实测 NPU 延迟 +3. **帧数消融**:真实数据到位后对比 frame_len=1/3/5 的验证误差,确认多帧收益 +4. **扩充 .mt 脚本**:增加动作脚本数量,缓解预训练数据不足 + +--- + +## 附录:文件变更清单 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `actpred/config.py` | 修改 | 新增 frame_len/attn_*;删除 history_encoder/action_hidden_dim;整除校验 | +| `actpred/model.py` | 重写 | 多帧 Transformer 注意力架构;删除 MLP/GRU 编码器与 concat 融合 | +| `actpred/dataset.py` | 修改 | 多帧窗口采样;batch key `image` → `images` | +| `actpred/train.py` | 修改 | 多帧适配(frame_len 透传、batch key) | +| `actpred/pretrain_mt.py` | 修改 | 全零多帧占位;打印信息更新 | +| `actpred/predict.py` | 修改 | `predict(images, history)` 多帧 API;CLI `--image` → `--images` | +| `actpred/export_onnx.py` | 修改 | 多帧 dummy 输入;input_names 更新 | +| `actpred/test_actpred.py` | 修改 | 56 项用例(新增多帧/校验/拒载/帧数错误等) | +| `actpred/README.md` | 修改 | 架构图、CLI 示例、配置表、API 文档同步 | +| `actpred/checkpoints/pretrain_best*.pt` | 重新生成 | 旧架构 checkpoint 已删除并重训 | diff --git a/docs/reports/bot4cj_python_migration_dev_doc.md b/docs/reports/bot4cj_python_migration_dev_doc.md new file mode 100644 index 0000000..39cbefb --- /dev/null +++ b/docs/reports/bot4cj_python_migration_dev_doc.md @@ -0,0 +1,400 @@ +# bot4cj 模块 Python 迁移开发方案 + +> 文档类型:功能开发计划 +> 模块:bot4cj → pybot(Python 迁移) +> 作者:自动生成 +> 日期:2026-08-09 + +--- + +## 1. 概述 + +### 1.1 背景与动机 + +`bot4cj` 是基于仓颉(Cangjie)编程语言和 Linux I2C 接口的机器人硬件脚本解析器,通过 `.mt` 矩阵脚本描述机器人硬件构成与动作序列,驱动 PCA9685 舵机控制板、MPU6050 传感器等真实硬件(宿主机为树莓派 aarch64)。 + +将其迁移到 Python 的动机: + +| 收益 | 说明 | +|------|------| +| 工具链门槛消除 | 仓颉编译器(cjc 1.1.3)+ cjpm + 4 个仓颉扩展包(ctype4cj/pthread4cj/spi4cj/lua4cj)→ 树莓派系统自带 Python3,零编译 | +| 与 actpred 生态打通 | actpred(动作预测,PyTorch)已有 Python 版 `.mt` 解析器(`mt_parser.py`),迁移后"大脑(actpred)↔ 身体(bot4cj)"共享同一语言与数据结构,DRL 闭环开发成本大幅降低 | +| Lua 扩展层可消除 | 仓颉版引入 Lua 是为了运行时可扩展硬件驱动;Python 本身即脚本语言,硬件驱动直接写成 Python 插件模块,砍掉 lua4cj + liblua5.4 + C 函数注册整层 | +| C/C++ 共享库大部分可消除 | libi2c4cj.so 的功能可用 `fcntl.ioctl` + `os.read/write` 纯 Python 实现;矩阵/FFT/CNN 由 numpy/PyTorch(actpred 已有)承担 | +| 审计遗留一并修复 | [bot4cj 代码审计](../reviews/bot4cj_CODE_AUDIT_20260808.md) 15 项发现(3 P0 + 5 P1 + 7 P2)全部待修复,迁移时逐项闭环 | +| 与项目主流技术栈对齐 | micro_servers 业务服务以 Python/C++20 为主,仓颉是唯一的孤立技术栈,维护人手受限 | + +**性能评估**:bot4cj 的执行节奏由硬件决定(舵机 50Hz PWM、I2C 100kHz~400kHz、动作行间 20~500ms 延时),解析与调度不是瓶颈,Python 的性能完全够用;且 Python 插件替代 Lua 桥接后,单次硬件调用的框架开销反而下降约两个数量级(详见附录 A 的量化分析)。这与 db2/captcha2 等"Python→C++ 求性能"的方向相反,是"仓颉→Python 求生态与可维护性"。 + +### 1.2 设计目标 + +1. **`.mt` 脚本零改动**:现有 `scripts/*.mt` 全部原样可用,块语法(`@Alias`/`@I2C`/`@PCA9685`/`@MPU6050_BIND`/`@Speaker`/`@Servo`/`@Action`)、`-` 占位符语义、`#` 注释、`;` 行结束符完全兼容。 +2. **CLI 兼容**:保持 `cat scripts/std.mt | ./bot` 的 stdin 管道用法与 `--help`/`--test` 分支。 +3. **零第三方依赖的核心**:解析器 + 硬件层仅用标准库(`fcntl`/`os`/`struct`/`threading`/`json`/`argparse`),树莓派上无需 venv 即可运行;numpy 仅测试/工具链可选。 +4. **硬件插件化(Python 替代 Lua)**:未知 `@Block` 路由到 `modules/.py` 插件,保留 build/call/destroy 三段生命周期协议。 +5. **审计 15 项发现全部修复**:见 §5 修复映射表。 +6. **可测试**:pytest 单元测试覆盖解析器、占位符语义、Servo 校准、插件加载;硬件层可用 Mock I2C 在无硬件的 x86 上跑通全流程。 + +### 1.3 不做什么 + +- **不迁移 math/NN/CNN/FFT/CUDA 层**(`src/math/*.cj`、`src/cpp/cnn/`、`src/cpp/fft*`、`src/cuda/`):这部分是为 DRL 预留的自研神经网络库,功能已被 actpred(PyTorch)完全覆盖且质量更高。DRL 闭环未来直接调 actpred,不重复造轮子。相应地 `--test nn/cnn/cnn1` 分支不迁移。 +- **不迁移 `.bot` 旧版脚本支持**:`scripts/*.bot` 是历史遗留,仓颉版本身也未解析它。 +- **不改 `.mt` 语法**:`@Action_Auto` 等语法演进(旧分析 D11)留待后续版本。 +- **不引入 smbus2/RPi.GPIO 等三方库**:I2C 用 `fcntl.ioctl(fd, I2C_SLAVE=0x0703, addr)` 直接实现,与仓颉版 `I2C.cj`/`I2C.c` 行为逐字节对齐。 +- **不删除 bot4cj 目录**:遵循项目"原版与重写版并存 → 验证通过后切换"的渐进策略(同 db→db2、captcha→captcha2、smtp→smtp3)。 + +--- + +## 2. 现状分析 + +### 2.1 仓颉版架构分层 + +``` +.mt 矩阵脚本(硬件定义 + 动作序列) + ↓ +MatrixParser(P.cj,FSM 逐字符解析)→ MatrixObject 链表 + ↓ +RobotStruct(r.cj)createHardVars():按 @块名分发到硬件变量工厂 + MatrixVars_Hardware 基类 + 三闭包:rise/things/free + ↓ +硬件实现 hd/*.cj:I2C(20 槽锁数组)、Servo、PCA9685、MPU6050、Speaker + + Lua 扩展 modules/*.lua(未知 @Block 的通用驱动,经 C 函数注册调 I2C/SPI) + ↓ +系统库 .so:libi2c4cj(I2C glibc 封装)、libsnd4cj(音频)、libmt4cj/libfft4cj/libcnn4cj(数学) +``` + +### 2.2 迁移范围源码清单 + +| 仓颉源文件 | 行数 | 职责 | 迁移去向 | +|-----------|------|------|---------| +| `src/main.cj` | 178 | CLI 入口、stdin 管道、--test 分支 | `pybot/main.py` | +| `src/P.cj` | 264 | MatrixParser(FSM)+ MatrixObject + MatrixSaver + 解析异常 | `pybot/parser.py` | +| `src/r.cj` | 1072 | RobotStruct + 硬件变量基类 + 6 个工厂 + Lua 状态机桥接 | `pybot/robot.py` + `pybot/hardvars.py` | +| `src/hd/I2C.cj` + `src/cpp/I2C.c` | 340+ | I2C glibc 封装 + 每 fd 互斥锁 | `pybot/hd/i2c.py` | +| `src/hd/Servo.cj` | 31 | max/min/offset 校准 + 负 max 角度翻转 | `pybot/hd/servo.py` | +| `src/hd/MPU6050.cj` | 244 | 6 轴传感器 + 后台采样线程 + 卡尔曼 | `pybot/hd/mpu6050.py` | +| `src/hd/snd.cj` + `src/cpp/snd.cpp` | 23+ | 扬声器(仓颉侧仅 sleep 占位) | `pybot/hd/speaker.py` | +| `src/hd/PCA9685.cj` | — | 已标记"由 Lua 取代" | 不迁移(走插件) | +| `modules/*.lua`(PCA9685/ADS1115/ADS1256/SPI/TEST) | — | Lua 硬件驱动 | `pybot/modules/*.py` 插件 | +| `src/math/*.cj`、`src/cpp/cnn|fft|Matrix|kalman`、`src/cuda/` | ~2000 | 自研 NN/数学库 | **不迁移**(actpred 承担);卡尔曼滤波例外,MPU6050 需要,~40 行纯 Python | + +### 2.3 关键语义(必须逐项对齐) + +1. **占位符 `-`**:递归沿用上一行同列值;首行为 `-` 时,`parseMt` 解析为 `0`(数值列),`getMt` 返回字面 `-`。 + ⚠️ 注意与 actpred `mt_parser.py` 的差异:后者首行 `-` 用 `@Servo` 初始角度填充(这是 ML 数据管线的语义)。**pybot 运行时语义以仓颉版为准(填 0)**,因为运行时 Servo 有 max/min 钳位保护;数据管线语义留在 actpred 不变。 +2. **@Action 列名 `dev.param`**:点号拆分为硬件名 + 参数名;参数名经 `@Alias` 表转换(`A`→`angle`、`F`→`frequency`)后传入硬件。 +3. **每行执行**:同一行按硬件聚合参数字典,逐硬件调用一次 `play()`;行间时延由 `spk.delay` 参数承担。 +4. **硬件生命周期**:`active()` 按创建顺序 build,`deactive()` 倒序 destroy,且必须在 `try/finally` 中保证执行。 +5. **Servo 角度算法**:`a = angle + offset; a = clamp(a, |min|, |max|); if max < 0: a = 180 - a`。 +6. **PCA9685 脉宽映射**:50Hz、0°→0.5ms、180°→2.5ms、`offval = width * 4096 / 20`、寄存器 `0x06 + 4*channel` 写 4 字节。 +7. **MPU6050**:±500°/s、±8g、10ms 采样周期、卡尔曼滤波(Q=20, R=200)、roll/pitch 由加速度计反正切求得。 +8. **I2C 互斥**:同一 fd 上"设地址 + 读/写寄存器"必须是原子序列(多设备共享总线)。 + +### 2.4 审计遗留(迁移时修复的输入清单) + +来自 [bot4cj_CODE_AUDIT_20260808.md](../reviews/bot4cj_CODE_AUDIT_20260808.md):3 项 P0(S01 列数校验恒假 / S02 调试 print 污染 stdout / S03 扬声器无实声)、5 项 P1(S04 转义不一致 / S05 MPU6050 地址重复 / S06 有符号读寄存器 / S07 Lua 路径硬编码 / S08 文档滞后)、7 项 P2(无测试、注释代码、锁扫描 O(n)、无编译告警、消费端报错不可读、线程退出、GC 析构不确定)。 + +--- + +## 3. 目标架构 + +### 3.1 目录结构 + +``` +pybot/ +├── pybot/ +│ ├── __init__.py +│ ├── main.py # CLI 入口(argparse:--help / --test / -i / -o 预留) +│ ├── parser.py # MatrixParser + MatrixObject + MatrixSaver + 解析异常 +│ ├── robot.py # RobotStruct(createHardVars / active / run / deactive / save) +│ ├── hardvars.py # HardwareVar 基类 + Alias/I2C/Servo/Speaker/MPU6050_BIND 工厂 +│ ├── plugin.py # Python 插件加载器(替代 Lua 状态机桥接) +│ └── hd/ +│ ├── __init__.py +│ ├── i2c.py # fcntl.ioctl I2C + 每 fd 锁(dict + threading.Lock) +│ ├── servo.py # 舵机校准与翻转 +│ ├── mpu6050.py # 传感器 + threading.Thread 采样 + kalman +│ ├── kalman.py # 一维卡尔曼滤波(~40 行) +│ └── speaker.py # 正弦波发声(可选 alsaaudio,默认 aplay 子进程 / 静默降级) +├── modules/ # 硬件驱动插件(Python 替代 Lua) +│ ├── PCA9685.py +│ ├── ADS1115.py +│ ├── ADS1256.py +│ └── SPI.py +├── scripts -> ../bot4cj/scripts # 软链或复制,.mt 脚本共享 +├── tests/ +│ ├── test_parser.py +│ ├── test_hardvars.py +│ ├── test_servo.py +│ └── mock_i2c.py # Mock I2C 总线(记录寄存器写序列,供断言) +├── _run.sh # cat x.mt | bash _run.sh(exec python3 -m pybot) +├── _set.sh # 无编译;可选建 venv 装 pytest +├── requirements.txt # 核心为空;[dev] pytest +└── README.md +``` + +### 3.2 数据流(与仓颉版一致) + +``` +stdin → MatrixParser.parse()(逐块产出 MatrixObject) + → RobotStruct.create_hardvars()(按块名分发工厂;未知块 → plugin.load("modules/.py")) + → robot.active()(按序 build) + → robot.run()(逐行 @Action:聚合参数 → 逐硬件 play(dict)) + → robot.deactive()(倒序 destroy,try/finally 保证) +``` + +### 3.3 关键映射决策 + +| 仓颉版机制 | Python 版方案 | 理由 | +|-----------|--------------|------| +| `JsonValue` 统一通信协议 | 原生 `dict`(进程内直接传递) | 无跨语言边界后无需序列化;插件协议文档化 dict 键约定 | +| 三闭包 rise/things/free | `HardwareVar` 基类三方法 `build()/play(params)/destroy()`,子类覆写 | Python 惯用法;闭包捕获改为实例属性 | +| Lua 状态机 + C 函数注册(i2c_open 等 11 个) | `importlib` 动态加载 `modules/.py`,插件直接 `from pybot.hd import i2c` | 消除整个 FFI 层;插件与核心同语言 | +| 20 槽 I2C 锁数组(O(n) 线性扫描) | `dict[fd → threading.Lock]` + 全局注册锁 | 修复审计 Q03;容量不再受限 | +| pthread_create/pthread_cancel(MPU6050 采样) | `threading.Thread(daemon=True)` + `threading.Event` 停止标志 | 修复审计 Q06/D3,安全退出 | +| 仓颉 GC 终结器不确定 | 上下文管理器 `with RobotStruct(...) as robot:` + `atexit` 兜底 | 修复审计 Q07/D1,确定性释放 fd | +| cjpm build + Makefile .so | 无构建步骤 | 树莓派上 `git pull` 即部署 | + +### 3.4 插件协议(modules/*.py,替代 Lua build/call/destroy) + +```python +# modules/PCA9685.py —— 与 PCA9685.lua 逐语义对齐 +def build(ctx: dict) -> dict: + """ctx: 元数据(fd/address/... 来自 @PCA9685 块行 + 引用硬件的 play() 展开) + 初始化:重启 → SLEEP → 设 prescale(50Hz) → 唤醒""" +def call(ctx: dict) -> dict: + """ctx 额外含实时参数(channel/angle);写 4 个 PWM 寄存器""" +def destroy(ctx: dict) -> dict: + """复位 PCA9685""" +``` + +- 插件查找顺序(修复审计 S07):`$PYBOT_MODULES_PATH`(冒号分隔多路径)→ 包内置 `modules/` → 当前工作目录 `./modules/`。 +- 加载失败抛出含插件名与搜索路径的 `PluginNotFound` 异常。 + +--- + +## 4. 详细设计 + +### 4.1 parser.py + +- **MatrixObject**:`name: str` + `rows: list[list[str]]`;提供 `__getitem__((row, col))`、`index_of_col(row, key)`、`save(writer)`。越界访问抛 `MatrixIndexError(块名, 行, 列)`(修复审计 Q05 的报错可读性)。 +- **MatrixParser**:保持 FSM 逐字符解析(非正则),状态与仓颉版一致(nl/cm/nm 三标志),原因:`@` 块嵌套注释、行内注释、任意空白分隔的兼容性已被现有脚本验证。 + - 修复 S01:`submit_line` 用 `len(grp) != len(rows[-1])` 校验列数,不一致抛 `DoesNotSync(行号, 期望列数, 实际列数)`。 + - 修复 S02:删除逐字符 debug 打印;提供 `--verbose` 时输出到 **stderr**(stdout 保留给未来管道通信)。 + - 修复 S04:Python 无 Rune/String 混淆问题,换行统一 `"\n"` 字符比较;补充单测覆盖"漏写分号跨行"场景。 +- **占位符语义**(§2.3-1):`get_mt(obj, row, col)` 递归向上;`parse_mt(obj, row, col, type)` 首行 `-` 返回 `type("0")`。 +- **MatrixSaver**:保持与仓颉版相同的输出格式(`@Name` / 空格分隔 / `;` 结尾 / `@` 收尾),保证 `--test matrix_o/matrix_2` 的解析↔序列化往返可用。 + +### 4.2 hd/i2c.py + +```python +I2C_SLAVE = 0x0703 +class I2CBus: + def __init__(self, path): self.fd = os.open(path, os.O_RDWR); _register_lock(self.fd) + def set_addr(self, addr): fcntl.ioctl(self.fd, I2C_SLAVE, addr) + def write_reg(self, reg, val): os.write(self.fd, bytes([reg, val])) + def read_reg(self, reg) -> int: # 返回 0..255 无符号(修复 S06) + os.write(self.fd, bytes([reg])); return os.read(self.fd, 1)[0] + def transaction(self): # with bus.transaction(): 原子序列(设地址+读写) +``` + +- 锁注册表:`_LOCKS: dict[int, threading.Lock]` + `_MAIN_LOCK`;`transaction()` 上下文管理器持锁,替代仓颉版 `I2cLock_lock/unlock` 手动配对。 +- `read_reg` 天然返回无符号 int(`bytes[0]` ∈ 0..255),16 位拼接 `(hh << 8) | ll` 后用 `struct.unpack('>h')` 转有符号——修复 S06 的符号扩展错误且保留传感器数据的正确符号。 + +### 4.3 hd/servo.py + +与 `Servo.cj` 逐行对齐(§2.3-5),构造参数 `max/min/offset/set_angle_callback`。补单测:正装(max=90)、反装(max=-180 翻转)、offset 漂移、越界钳位。 + +### 4.4 hd/mpu6050.py + +- 寄存器常量与 `MPU6050.cj` 一致;**修复 S05**:`ADDR_0 = 0x68`、`ADDR_1 = 0x69`。 +- 采样线程:`threading.Thread` + `self._stop = threading.Event()`,循环 `while not self._stop.wait(0.01)`(10ms 周期且可即时打断)——修复 Q06。 +- 读传感器整体包在 `bus.transaction()` 中(设地址 + 14 字节连读),比仓颉版逐寄存器 12 次 I2C 往返更高效且原子。 +- 卡尔曼滤波参数保持 Q=20、R=200;roll/pitch 公式不变。 +- 数据出口:`latest()` 返回带 `threading.Lock` 保护的最近一帧滤波值(仓颉版 mutex 字段从未真正使用,此处补全)。 + +### 4.5 hd/speaker.py(决策 S03) + +仓颉版 `play()` 仅 sleep(实声 C++ 函数被注释)。Python 版决策:**实现真实发声,带静默降级**。 + +- 首选:标准库 `wave` + `subprocess.run(["aplay", "-q", tmp.wav])`——树莓派自带 alsa-utils,零 pip 依赖;正弦波生成用 `math.sin`(每次 ≤ 数秒 @48kHz,纯 Python 可接受),保留 5ms 淡入淡出。 +- `aplay` 不存在或播放失败:log 一次警告后仅 sleep(delay),行为与仓颉版一致(延时职责不丢失)。 +- `delay` 语义不变:`play()` 阻塞 `delay` 毫秒(发声与延时并行时取延时为准)。 + +### 4.6 robot.py / hardvars.py + +- `RobotStruct` 保持仓颉版结构:`C: list[MatrixObject]` + `M: dict[str, HardwareVar]`(Python dict 有序,天然满足"倒序 destroy"需求)。 +- `create_hardvars()` 分发表:`{"I2C": I2CVar, "Alias": AliasVar, "Servo": ServoVar, "Speaker": SpeakerVar, "MPU6050_BIND": MPU6050Var}`;`Action` 跳过;未知块 → `plugin.PluginVar`。 +- 重名键检查(仓颉版 FIXME r.cj:911):同名硬件变量重复定义时,先 `destroy()` 旧值再覆盖,并输出 stderr 警告。 +- `run()` 逐行执行 `@Action`;列名合法性(必须含一个 `.`)在**执行前**整表校验一次,报错含列号与列名(配合 Q05 修复)。 +- 上下文管理器:`__enter__`→`active()`,`__exit__`→`deactive()`;`main.py` 用 `with` 替代仓颉版 try/finally。 + +### 4.7 main.py + +| 仓颉版 CLI | Python 版 | 说明 | +|-----------|-----------|------| +| (无参)stdin 管道 | 同 | `cat std.mt | ./bot` 不变;另支持 `pybot path/to/x.mt` 位置参数(增强,非破坏) | +| `--help` | argparse 自动生成 | 文案更新 | +| `--test nn/cnn/cnn1` | **移除**(见 §1.3) | NN 由 actpred 承担 | +| `--test matrix_o/matrix_2` | `--test roundtrip ` | 解析↔序列化往返测试,路径改为参数(仓颉版硬编码 `ss/test.mt`) | +| `-i` / `-o`(DRL 预留) | 保留参数位,标注 reserved | 未来对接 actpred | +| (新增)`--dry-run` | 解析 + 构建硬件变量但不开 I2C,Mock 总线打印动作序列 | 无硬件调试 .mt 脚本 | +| (新增)`--verbose` | FSM 与硬件调用日志到 stderr | 替代 S02 删掉的调试打印 | + +### 4.8 日志规范 + +- 所有诊断输出走 `logging`(stderr),stdout 保留纯净——为未来 actpred 管道集成预留(审计 §六 的阻塞项 S02 根治)。 + +--- + +## 5. 审计发现修复映射表 + +| 审计 ID | 级别 | 仓颉版问题 | Python 版修复方式 | 落点 | +|---------|------|-----------|------------------|------| +| S01 | P0 | submitLine 列数校验恒假 | `len(grp) != len(rows[-1])` 显式校验 + DoesNotSync 带期望/实际列数 | parser.py | +| S02 | P0 | parse() 逐字符 print 污染 stdout | 移除;--verbose 走 stderr logging | parser.py / main.py | +| S03 | P0 | 扬声器仅 sleep 无实声 | wave + aplay 实现发声,缺 aplay 静默降级为 sleep | hd/speaker.py | +| S04 | P1 | r"\n" 与 r'\n' 转义不一致 | Python 无此坑;补"漏分号跨行"单测防回归 | tests/test_parser.py | +| S05 | P1 | MPU6050 ADDR_1 应为 0x69 | `ADDR_1 = 0x69` | hd/mpu6050.py | +| S06 | P1 | readI2cReg 返回有符号 Int8 | read_reg 返回 0..255;16 位拼接后 struct 显式转有符号 | hd/i2c.py | +| S07 | P1 | Lua 路径硬编码 ./modules/ | `$PYBOT_MODULES_PATH` → 包内置 → cwd 三级查找 | plugin.py | +| S08 | P1 | README 已知问题表滞后 | pybot README 重写;bot4cj README 标注维护状态 | README.md | +| Q01 | P2 | 无单元测试 | pytest:解析器/占位符/Servo/插件/Mock I2C 全流程 | tests/ | +| Q02 | P2 | 大量注释旧代码 | 新代码库不携带 | — | +| Q03 | P2 | I2C 锁 O(n) 线性扫描 | dict[fd → Lock] | hd/i2c.py | +| Q04 | P2 | Makefile 告警关闭 | 无 C 编译;CI 跑 `python -m pyflakes`(可选) | _set.sh | +| Q05 | P2 | 列数不匹配报错不可读 | MatrixIndexError 带块名/行/列 + run() 前整表校验 | parser.py / robot.py | +| Q06 | P2 | pthread_cancel 非安全点退出 | threading.Event 优雅停止 | hd/mpu6050.py | +| Q07 | P2 | GC 析构不确定,fd 泄漏 | with 上下文管理器 + atexit 兜底 | robot.py / main.py | + +--- + +## 6. 测试计划 + +### 6.1 单元测试(无硬件,x86 可跑) + +| 用例组 | 覆盖 | +|--------|------| +| test_parser | 全部 `scripts/*.mt` 解析成功;列数不一致抛 DoesNotSync;漏分号跨行;注释/空行/CRLF;roundtrip(解析→save→再解析等价) | +| test_placeholder | `-` 递归沿用;首行 `-` 数值列=0;与 actpred mt_parser 语义差异的显式断言(防止两边被误"统一") | +| test_servo | 正装/反装翻转/offset/钳位边界 | +| test_hardvars | @Alias 转换;@Action 列名拆分;同行参数聚合;重名覆盖警告 | +| test_plugin | 插件三级路径查找;PluginNotFound 报错内容;PCA9685.py 用 Mock I2C 断言寄存器写序列(prescale=121、reg=0x06+4*ch、offval 字节序) | +| test_mpu6050 | Mock 总线注入 14 字节样本 → 有符号拼接正确(高字节 ≥ 0x80 场景,回归 S06);Event 停止 1s 内退出 | + +### 6.2 实机验证(树莓派 + Dashun 蜘蛛机器人) + +1. `cat scripts/std.mt | bash _run.sh`:站立姿态与仓颉版目测一致(10 舵机角度逐一对照 --verbose 日志)。 +2. `jump.mt` / `lie-down.mt` / `tilt-*.mt` 逐个回放,动作时序无可感知差异。 +3. 双 MPU6050(0x68 + 0x69)同总线并行采样(回归 S05),roll/pitch 数值与仓颉版单设备输出同量级。 +4. 扬声器 `spk.F` 发声可闻(回归 S03),无爆音(淡入淡出生效)。 +5. Ctrl-C 中断:舵机停止、I2C fd 全部释放(`ls /proc//fd` 验证)、MPU6050 线程退出。 + +### 6.3 交叉验证 + +- `--dry-run` 模式下用 Mock 总线记录全部 I2C 写序列,与仓颉版(打补丁记录)同脚本输出逐字节 diff——作为迁移等价性的最强证据(可参考 captcha2 迁移的像素级验证经验)。 + +--- + +## 7. 里程碑 + +| 阶段 | 内容 | 产出 | +|------|------|------| +| M1 解析器 | parser.py + 全量单测 + roundtrip | 全部 scripts/*.mt 解析通过 | +| M2 硬件抽象 | hardvars.py + robot.py + plugin.py + Mock I2C | --dry-run 全流程可跑(x86) | +| M3 硬件落地 | i2c.py / servo.py / mpu6050.py / speaker.py + modules/*.py | 树莓派实机 std.mt 站立 | +| M4 等价性验证 | §6.2 实机回放 + §6.3 写序列 diff | 验证报告 | +| M5 切换 | README/INDEX 更新、bot4cj 标注 legacy、aibox/BotOps 若有引用改指 pybot | 并存期结束评估 | + +--- + +## 8. 风险与对策 + +| 风险 | 影响 | 对策 | +|------|------|------| +| Python GIL + 解释器抖动影响动作时序 | 舵机动作节奏轻微不均 | 动作行间延时 ≥ 20ms,抖动 <1ms 不可感知;MPU6050 采样用 Event.wait 定时,不忙等;量化依据见附录 A | +| aplay 子进程首次启动延迟 | 首个音符滞后 | build() 时预热(播放 0 长度静音);或后续可选接 alsaaudio | +| 仓颉版行为的未记录细节 | 等价性缺口 | §6.3 写序列逐字节 diff 兜底 | +| .mt 脚本方言分裂(pybot vs actpred 解析器) | 语义漂移 | test_placeholder 显式断言两者差异;长期可评估 actpred 复用 pybot parser 的只读模式 | +| 树莓派 Python 版本参差 | 语法兼容 | 目标 Python ≥ 3.9(树莓派 OS Bullseye 起自带 3.9) | + +--- + +## 9. 与 actpred 的集成展望(非本期范围) + +迁移完成后,"大脑-身体"闭环的最短路径: + +``` +actpred predict.py(图像+历史 → 动作向量 action_dim=18) + → 向量转 @Action 单行(pybot 提供 action_row_from_vector() 工具函数) + → pybot RobotStruct 增量执行(对应仓颉版 TODO:@Action 位置指示器) + → MPU6050 姿态反馈回流 actpred(DRL 奖励信号) +``` + +同语言后,上述管线可在单进程内完成,无需序列化协议——这正是本次迁移最大的战略收益。 + +--- + +## 附录 A:性能评估依据 + +迁移评审中最常见的两个疑虑是"Python 做驱动会不会慢"与"Python 替代 Lua 会不会引入开销"。本附录给出量化分析,结论:**两者均不构成风险,后者反而是性能收益**。 + +### A.1 Python 做硬件驱动的时间尺度分析 + +bot4cj 架构中所有微秒级时序都已下沉到专用硬件芯片(PCA9685 自主生成 PWM 波形),主机侧只发送设定值,该路径的瓶颈是 I2C 总线物理速率,与宿主语言无关: + +| 环节 | 耗时量级 | 由谁承担 | +|------|---------|---------| +| 舵机 PWM 波形(50Hz、0.5~2.5ms 脉宽) | µs 级精度 | PCA9685 硬件芯片,主机不参与 | +| I2C 写一个寄存器(100kHz 标准模式) | ~200–300 µs/次 | 总线物理速率,语言无关 | +| Python 一次 `os.write`/`ioctl` 调用开销 | ~2–5 µs | 解释器 + 系统调用 | +| 设一个舵机角度(4 次寄存器写) | ~1 ms | 总线 ≈ 99%,Python ≈ 1% | +| 舵机机械转动 60° | ~100–200 ms | 物理惯性 | +| `.mt` 动作行间延时 | 20–500 ms | 脚本显式指定 | + +Python 解释开销比总线时间低约两个数量级,比机械动作低四五个数量级。逐项检查潜在慢点: + +- **MPU6050 10ms 采样循环**:14 字节连读 ≈ 1.5–2ms 总线时间 + 卡尔曼滤波 6 次浮点(纯 Python 数 µs),周期内富余充足;且连读比仓颉版逐寄存器 12 次往返(每次重发从机地址)更快。 +- **正弦波生成**:48kHz 下每秒音频约 4.8 万次 `math.sin`,纯 Python 10–30ms 算完,发声前一次性生成,可接受。 +- **GIL/GC**:仅主线程 + 1 个采样线程,GIL 无竞争;GC 停顿亚毫秒级,低于 `sleep()` 本身的 ms 级精度。 + +Python 不适用的边界(bot4cj 均不涉及):主机 GPIO bit-bang 生成 PWM/WS2812 单线协议、>1kHz 闭环控制、硬实时保证(Linux 非 RTOS,仓颉版同样做不到)。若未来 DRL 需要高频控制环,正确做法是下沉到协处理器(如 RP2040),属架构问题而非语言问题。 + +### A.2 Python 插件替代 Lua 桥接的开销对比 + +单论解释器速度 Lua 5.4 快于 CPython,但现有 Lua 路径的开销主要在跨语言桥接而非驱动代码本身。仓颉版每次硬件调用([r.cj MtVars_Lua.things](../../bot4cj/src/r.cj#L311-L331))的实际成本: + +| 步骤 | 实现 | 开销量级 | +|------|------|---------| +| 元数据深拷贝 | `JsonValue.fromStr(metadata.toJsonString())`——JSON 序列化+反序列化一整轮 | ~10–50 µs | +| 参数推入 Lua 表 | 每键 `mallocCString` + `Lua_pushstring/setfield` + `free`,跨仓颉→C→Lua 三层 FFI | ~几 µs/键 | +| `Lua_pcallk` + 返回表遍历 | `Lua_next` 循环 + 每键值 `Lua_tostring` 转仓颉 String | ~几 µs | +| 强制调试打印 | 仓颉侧 2 次 `println` + Lua 侧 `print_table` 全表打印 | ~几十 µs 至 ms 级(终端 I/O) | +| Lua 内 4 次 `i2c_write_reg` | 每次跨 Lua→C→仓颉 @C 三层 | 桥接 ~1 µs + 总线 ~250 µs | + +合计框架开销 ~0.1–1 ms/次,与 I2C 总线时间同量级。Python 插件路径的对应成本: + +| 步骤 | 实现 | 开销量级 | +|------|------|---------| +| 上下文构造 | `{**metadata, **params}` dict 合并(同进程传引用,无序列化) | ~0.5 µs | +| 插件调用 | 同解释器直接函数调用 | ~0.1 µs | +| I2C 写 | 插件直接 `i2c.write_reg()`,一层 `os.write` 到底 | 系统调用 ~2 µs + 总线 ~250 µs | +| 诊断输出 | 默认关闭,`--verbose` 时走 stderr logging(即审计 S02 修复) | 0 | + +框架开销 ~1–2 µs/次,比 Lua 桥接低约两个数量级。插件内部计算(脉宽换算、移位掩码)在两种解释器上的差距为百纳秒级,相对 1ms 总线时间可忽略。 + +**结论**:Lua 层在仓颉版存在的唯一理由是"编译型宿主做不了运行时扩展",为此付出 JSON 往返 + 五层 FFI(仓颉→C→Lua→C→仓颉→glibc)的税;宿主换成 Python 后调用链缩短为 Python→glibc 一层,此项迁移是删除一整层间接性,而非语言互换。 + +--- + +## 参考文档 + +- [bot4cj 代码审计报告](../reviews/bot4cj_CODE_AUDIT_20260808.md)(15 项发现) +- [bot4cj 深度分析报告](bot4cj_analysis_20260525_00.md)(D1-D29) +- [actpred 与 bot4cj 研究报告](actpred_bot4cj_research_20260809.md) +- [bot4cj README.robot.md](../../bot4cj/README.robot.md) +- [actpred mt_parser.py](../../actpred/mt_parser.py)(Python 解析器先例) + +--- + +*本文档为开发计划,实施前需结合实际硬件环境评估。* From cfe30a867c6c29004ad59bb26d3a2ec241f979ed Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 02:17:35 +0800 Subject: [PATCH 014/196] =?UTF-8?q?docs(core):=20=E5=B0=86=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=80=BB=E8=A7=88=E5=90=88=E5=85=A5=20core=20README?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9D=97=20onboarding=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 core/README.md:整合运行模式/CLI、任务池模型(Tttask/HttpTask/ ConfsTask)、进程托管核心(PPool/ProcessTask/pmc_mtd)、IPC 层(Mq+Pipe)、 HTTP 管理接口(5200 gateway 验签)、内嵌 confs 等章节;修正已废弃的 users/extra 目录说明,改为实际文件结构树 - 新增 docs/reports/core_module_overview.md 作为底层逐文件分析记录 --- core/README.md | 196 ++++++++++++++++++++++----- docs/reports/core_module_overview.md | 108 +++++++++++++++ 2 files changed, 271 insertions(+), 33 deletions(-) create mode 100644 docs/reports/core_module_overview.md diff --git a/core/README.md b/core/README.md index 7d776a4..2452452 100644 --- a/core/README.md +++ b/core/README.md @@ -1,29 +1,151 @@ -# 进程机器(PMC) -这是一个可以创建、管理子进程以及模块的程序。 +# 进程机器(PMC · core) -## pmc是一个什么项目? -这是一个可扩展的模块,可以在文件夹下建立单独的模块,pmc核心模块可以托管运行这些模块。 +> 进程托管服务 / 微服务编排中枢 · 可执行 `nidus.out` · systemd 服务 `Nidus` +> 版本:core v0.1.0(内嵌 confs 合并版) +## pmc 是一个什么项目? -## 为什么要做一个这样的项目? -2024年的时候,我想做一个树莓派机器人,准备用python来写控制程序,可是当时python的多线程能力较弱,所以想到使用子系统,就自己写了一个进程托管程序 +PMC(Process Machine,进程机器)是一个可以创建、管理子进程以及模块的进程托管程序。你可以在它的托管下运行独立的业务模块,由 PMC 负责拉起与守护。它是整个 UniServe Framework 的**进程编排中枢**——读取 `init.txt` 自启动列表,顺序拉起并托管全部业务子进程(fsys / dnfs2 / db2 / ca / gateway / waf / captcha2 / smtp / ai_review / aibox / redirect80),并提供本地 HTTP 管理接口与内嵌配置服务。 +崩溃守护由 systemd(`Nidus.service`)负责,PMC 自身**不含**自动重启子进程逻辑。 -## 项目的文件结构是怎样的? -users用户可以在目录下创建子模块,extra下面还有一些额外的模块。 +### 为什么要做这个项目? -请留意模块的README文件 +2024 年,作者想做一个树莓派机器人,准备用 Python 写控制程序,但当时 Python 的多线程能力较弱,于是想到使用子进程(子系统)来分担工作,自己动手写了一个进程托管程序。后来演进为整套轻量级微服务架构的底座。 +## 模块文件结构 + +``` +core/ +├── Makefile # 构建 / 安装(VERSION=0.1.0, APP_NAME=nidus) +├── init.txt # 自启动列表(逐行 bash 命令,# 开头为注释) +├── Nidus.service # systemd 单元(User=root, ExecStart=_run.sh) +├── _run.sh # 启动:nidus.out -i init.txt --serve 5200 --confs 8010 +├── _set.sh # 构建 + 创建 configs 符号链接 + make install +├── src/ +│ ├── main.cpp # 入口:双模式 + 信号 + taskPool 生命周期 +│ └── main/ +│ ├── PPool.cpp # 子进程池 +│ ├── ProcessTask.cpp # boost.process 子进程封装 +│ ├── pmc_mtd.cpp # list / kill / exec / parse_self_init_list +│ ├── subsys_call.cpp # 跨机子系统调用(MQ 发令 + Pipe 回传) +│ ├── Tttask.cpp # 消息队列通信任务 +│ ├── HttpTask.cpp # 管理 HTTP 接口(:5200) +│ └── ConfsTask.cpp # 内嵌配置服务(:8010) +└── include/ + ├── main/*.hh # PPool / ProcessTask / pmc_mtd / subsys_call / Tttask / HttpTask / ConfsTask + ├── tt/Pipe.h # IPC:命名管道(FIFO) + ├── tt/Mq.h # IPC:消息队列 + ├── logs/Logger.hpp # 单例日志 + ├── main/ConfsLogC.hpp # 内嵌 confs 异步日志客户端 + └── cn/*.hpp # 中文化编程宏 +``` + +> 注:早期 README 提到的 `users/`、`extra/` 子模块目录已不再使用,模块托管统一改为通过 `init.txt` 启动列表拉起各业务服务。 ## 如何托管外部模块 -编辑启动列表程序,常为init.txt +编辑启动列表 `init.txt`,每行写一条启动命令(以 `#` 开头的行视为注释)。PMC 启动时会逐行拉起(先 `clr()` 清理已退出进程,再 `crtp()` 创建)。**启动顺序很重要**(redirect80 → waf → ca → gateway …)。 -## PMC的安装方式 -``` bash -# 1.安装依赖 +## 运行模式与命令行 -# 安装UniServe的libth包 +PMC 支持两种工作模式: + +1. **命令行子系统调用**(一次性,非服务模式): + - `--send --exec `:向目标 PMC 的消息队列发启动命令并取回结果 + - `--send --list`:列出目标 PMC 运行的进程 + - `--send --kill `:杀死目标 PMC 的某个进程 +2. **HTTP 服务模式**:`--serve ` 启动本地管理接口(默认 `127.0.0.1:5200`,纯 HTTP,因 WAF 已终止 TLS)。 + +此外: + +- `-i ` / `--init`:指定自启动列表(常用 `init.txt`) +- `--confs `:启用内嵌 confs 配置服务(默认 `127.0.0.1:8010`) +- `-h` / `--help`:打印帮助与 API MAP(`/list` `/exec` `/kill` 路由) +- `-v` / `--version`:打印版本号 + +实际启动命令(`_run.sh`): + +```bash +./nidus.out -i init.txt --serve 5200 --confs 8010 +``` + +**生命周期**:注册 `SIGINT`/`SIGTERM` → `signalHandler` 唤醒条件变量 `cv` → 主线程 `cv.Wait()` 返回后遍历 `taskPool` 调用 `stop()` 优雅退出。 + +## 架构:任务池模型 + +PMC 把所有常驻能力抽象为 `ITask`,用 `libth` 的 `Thread`(带 `stop/wake/loop/clear` 回调状态机)封装,统一加入 `taskPool`: + +- **`Tttask`**(`tt/` 消息队列通信服务):以进程 pid 为名创建 boost.interprocess 消息队列,循环 `recv` 指令,回调 `parse_data_from_mq`。 +- **`HttpTask`**(`HttpTask.cpp`):在 `127.0.0.1:port` 起 `pmc::net::HttpServer`(纯 HTTP,无 TLS),注册 `/list` `/exec` `/kill`。 +- **`ConfsTask`**(`ConfsTask.cpp`):起 `pmc::net::RpcServer`(pymsg),提供配置分发。 + +三者并列由主线程启停。 + +## 架构:进程托管核心 + +- **`PPool`**(`PPool.hh/.cpp`):`std::list>`,接口 `crtp()`(create process)/ `clr()`(清理已退出)/ `kill(idx)` / `operator[]`。**注意 kill/list 用容器内下标而非 pid,代码内有 FIXME 标注不自然。** +- **`ProcessTask`**(`ProcessTask.hh/.cpp`):`boost::process::child` 封装,`start()` 直接 `make_unique(cmd)`,`stop()` 调 `terminate()`。当前编译走非 `__NEW_VER`(boost.process v1)分支。 +- **`pmc_mtd`** 命名空间(`pmc_mtd.cpp`): + - `list()` → 序列化所有子进程 `{index, pid, running, command}`。 + - `kill(pid)` → 按 pid 遍历 `ppool` 匹配后 `kill(i)`。 + - `exec(cmd)` → `ppool.crtp(cmd)`。 + - `parse_self_init_list(path)` → 逐行读 `init.txt`,去 `#` 注释与空行后 `clr()` + `crtp(row)`。 + +## 架构:进程间通信层(`include/tt/`) + +跨进程 / 跨机的进程管理采用「消息队列发令 + 命名管道回传」: + +- **`Mq`**(`tt/Mq.h`):`boost::interprocess::message_queue`,名字 `/`,容量 2048×100。发送端 `send(json)`,接收端 `try_receive`。 +- **`Pipe`**(`tt/Pipe.h`):FIFO 命名管道,路径 `/tmp/pst-`,`CREATOR` 负责 `mkfifo`/`unlink`。`openForRead(nonblock)` + `select` 超时 + `readPipe`。 + +指令 JSON:`{"pipe": "<回传管道名>", "type": "list"|"kill"|"exec", ...}`。 + +- 服务端:`parse_data_from_mq()` 收到 JSON → 按 `type` 调 `pmc_mtd::list/kill/exec` → 经 `Pipe` 写回结果。 +- 客户端:`subsys_call`(subsys_call.cpp):`send_*_and_recv_pipe(target, ...)` 先建 `Mq(target)` 发令,再开 `Pipe` 等回传(1 秒超时,失败返回 `"ERR"`)。 +- `HttpTask` 的 `listProcesses/execCommand/killProcess` 在 `target` 非空时走 `subsys_call` 转发到目标机器 PMC,为空则本机 `pmc_mtd`。 + +## 架构:HTTP 管理接口 + +- 仅监听 `127.0.0.1:5200`,纯 HTTP(WAF 已终止 TLS,无证书)。 +- 三个 POST 路由:`/list` `/exec` `/kill`,返回 `{"success", "message", "data"}`。 +- **管理员鉴权**(`verify_admin_token`):每个操作都先调 `gateway /admin/verify`(gateway 是 Token 签发/验签唯一权威,RSA 验签 + is_admin + 过期检查),`Authorization` 头优先、回退 `q4_token` Cookie;gateway 地址由 `GATEWAY_HOST`/`GATEWAY_PORT` 配置(默认 `127.0.0.1:8443`)。gateway 不可用或异常一律 `fail-closed` 返回 401。 +- 命令执行(`exec`)与 pid 解析同时支持 query 参数与 JSON body。 + +## 内嵌 confs 配置服务(v0.1.0 起) + +core 内置了统一配置服务(由 confs2 合并而来,pymsg RPC 协议,与 Python confs 完全兼容): + +```bash +./nidus.out -i init.txt --serve 5200 --confs 8010 +``` + +- `--confs `:启用内嵌 confs 配置服务(get/list/reload/health 四个路由) +- 不传 `--confs` 时行为与原版完全一致 +- 配置文件目录默认为 `./configs`(`_set.sh` 会创建指向 `../confs/configs` 的符号链接) +- 环境变量:`CONFS_ADDR` / `CONFS_PORT` / `CONFS_WORKERS` / `CONFS_DIR` + +`ConfsTask`(`ConfsTask.cpp`)由 confs2 移植,业务逻辑逐项对齐: + +- **`ConfigStore`**:线程安全(`mutex`),内存缓存 + `mtime` 变化自动重载;`get()` 在锁内 canonical 解析 + 前缀校验 + 存在性检查(无 TOCTOU);扩展名白名单(`.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`)+ 1 MiB 上限防路径遍历/DoS。 +- **路由**: + - `get`:取全文;带 `key` 时 INI 用 `Section.option` 取值(Section 大小写敏感、Option 不敏感)、JSON 用点路径取值。 + - `list`:列目录全部配置元信息(name/size/mtime/cached)。 + - `reload`:指定 name 重载,或省略 name 清空全部缓存。 + - `health`:status/config_dir/cached/uptime。 +- **`ConfsLogC`**(`ConfsLogC.hpp`):基于 `libmsg::AsyncQueue`,50 条/2 秒攒批异步写 `db:8005` 的 `Logs` 表,`DB_HOST`/`DB_PORT` 可配(默认 127.0.0.1:8005),fail-safe 静默丢弃;含日志注入防护(`sanitize_for_log` 过滤控制字符)。 + +## 支撑组件 + +- **`Logger`**(`logs/Logger.hpp`):单例,控制台输出,支持 DEBUG/INFO/WARNING/ERROR/CRITICAL 级别(注释标注为 DeepSeek 创作,TODO 改为文件存储)。 +- **`include/cn/`**:中文化编程宏(`中文化.hpp` 等),提供 C++ 关键字中文别名。 +- **依赖库**(Makefile):`libth` `libhttp`/`libhttpclient` `libmsg` `libcurl` + boost(`program_options`/`json`/`thread`/`filesystem`)+ `ssl`/`crypto`/`pthread`。 + +## PMC 的安装方式 + +```bash +# 1. 安装依赖 +# 安装 UniServe 的 libth 包 git clone https://github.com/gopsc/UniServe_libth cd UniServe_libth sudo bash _set.sh @@ -36,33 +158,41 @@ sudo apt install libboost-all-dev #sudo apt install libcurl4-openssl-dev #sudo apt install libcurl4-gnutls-dev -# 2. 创建项目 +# 2. 构建并安装 core git clone https://github.com/gopsc/UniServe_core cd UniServe_core -make -sudo make install +make # 等价于 make clear && make +sudo make install # 装入 /usr/local/bin/nidus + systemd +``` + +> `make install` 会把 `init.txt` 拷到 `/etc/Nidus/`、`Nidus.service` 拷到 `/etc/systemd/system`,并 `daemon-reload` + `enable` + `restart`。 + +本仓库内的快捷方式: + +```bash +bash core/_set.sh # make clear && make + 建 configs 符号链接 + make install +bash core/_run.sh # 直接 ./nidus.out -i init.txt --serve 5200 --confs 8010 ``` ## 加密通信机制(废弃) -PMC在启动时生成一个AES密钥,然后用一个RSA公钥将其加密发送给访问者。 -然后访问者与客户端之间的交流全部用此AES密钥加密 - - -## 内嵌 confs 配置服务(v0.1.0 起) -core 内置了统一配置服务(由 confs2 合并而来,pymsg RPC 协议,与 Python confs 完全兼容): - -```bash -./nidus.out -i init.txt --serve 5200 --confs 8010 -``` - -- `--confs `:启用内嵌 confs 配置服务(get/list/reload/health 四个路由) -- 不传 `--confs` 时行为与原版完全一致 -- 配置文件目录默认为 `./configs`(_set.sh 会创建指向 `../confs/configs` 的符号链接) -- 环境变量:`CONFS_ADDR` / `CONFS_PORT` / `CONFS_WORKERS` / `CONFS_DIR` +~~PMC 在启动时生成一个 AES 密钥,然后用一个 RSA 公钥将其加密发送给访问者,之后访问者与客户端之间的交流全部用此 AES 密钥加密。~~ 该机制已废弃,当前管理接口与子系统调用改用 `gateway` 的 Token 验签(见「HTTP 管理接口」与「进程间通信层」章节)。 ## 如何使用并发机器运行程序 -首先应该在模块中创建一个启动脚本,脚本中应该完成工作目录的切换、虚拟环境的激活,以及程序的启动。 +首先应该在模块中创建一个启动脚本,脚本中应该完成工作目录的切换、虚拟环境的激活,以及程序的启动。PMC 通过 `init.txt` 逐行执行这些启动脚本,从而把多个业务模块拉起并纳入托管。 +## 已知待办与风险 +1. **无子进程自动重启**:PMC 崩溃守护靠 systemd;单个业务进程退出后 PMC 不会拉起它(仅保留在 `ppool` 中标记非运行)。 +2. **`PPool` 用下标而非 pid 索引**:`kill(idx)` 语义不自然,FIXME 仍在。 +3. **内存泄漏 TODO**:`HttpTask`/`ConfsTask` 的 `start()` 获取资源后未确认释放(代码注释标注)。 +4. **`/tmp/pst-*` FIFO 残留**:`subsys_call` 异常路径可能留下管道文件。 +5. **管理接口强依赖 gateway**:`/admin/verify` 不可用时所有管理操作 fail-closed 拒绝(安全优先,但运维注意 gateway 必须在线)。 +6. **`exec` 命令注入面**:`pmc_mtd::exec` 直接 `boost::process::child(cmd)` 以 shell 执行,仅在已通过管理员鉴权后可用——务必保证鉴权不被绕过。 + +## 上手要点 + +- 调整自启动服务/顺序:编辑 `init.txt`(注释用 `#`)。 +- 管理接口调用:必须携带 admin token(经 gateway 签发),例如带 `Authorization: Bearer ` 或 `q4_token` Cookie 访问 `:5200`。 +- 构建:`core/_set.sh`(会先创建 `configs → ../confs/configs` 符号链接,保证内嵌 confs 零迁移);`make clear && make` 后 `make install` 装入 systemd。 diff --git a/docs/reports/core_module_overview.md b/docs/reports/core_module_overview.md new file mode 100644 index 0000000..a6f1d63 --- /dev/null +++ b/docs/reports/core_module_overview.md @@ -0,0 +1,108 @@ +# core 模块架构总览(进程托管服务 PMC / Nidus) + +> 适用版本:core v0.1.0(与 confs 内嵌合并版) +> 阅读对象:新接手 blackbird 项目的开发者 + +## 1. 模块定位 + +`core`(可执行 `nidus.out`)是整个 UniServe Framework 的**进程编排中枢**,负责: + +1. 读取 `init.txt` 自启动列表,顺序拉起并托管全部业务子进程(fsys / dnfs2 / db2 / ca / gateway / waf / captcha2 / smtp / ai_review / aibox / redirect80)。 +2. 提供本地 HTTP 管理接口(`127.0.0.1:5200`),支持 list / exec / kill 受管子进程。 +3. 内嵌统一配置服务 `ConfsTask`(pymsg RPC,`:8010`),平替独立 confs2 进程。 + +崩溃守护由 systemd(`Nidus.service`)负责,core 自身**不含**自动重启子进程逻辑。 + +## 2. 入口与运行模式(`src/main.cpp`) + +命令参数(boost::program_options): + +| 参数 | 作用 | +|------|------| +| `-i ` | 解析自启动列表 `init.txt` | +| `--serve ` | 启动 HTTP 管理接口(默认 5200,仅 loopback) | +| `--confs ` | 启用内嵌 confs 配置服务(默认 8010) | +| `--send/--exec/--list/--kill` | CLI 子系统调用模式(一次性,非服务模式) | +| `-h/-v` | 帮助 / 版本 | + +生命周期:注册 `SIGINT`/`SIGTERM` → `signalHandler` 唤醒条件变量 `cv` → 主线程 `cv.Wait()` 返回后遍历 `taskPool` 调用 `stop()` 优雅退出。 + +实际启动命令见 `_run.sh`: + +```bash +./nidus.out -i init.txt --serve 5200 --confs 8010 +``` + +## 3. 任务池模型(`taskPool` + `ITask`) + +core 把所有常驻能力抽象为 `ITask`,用 `libth` 的 `Thread`(带 `stop/wake/loop/clear` 回调状态机)封装: + +- **`Tttask`**(`tt/` 消息队列通信服务):以进程 pid 为名创建 boost.interprocess 消息队列,循环 `recv` 指令,回调 `parse_data_from_mq`。 +- **`HttpTask`**(`HttpTask.cpp`):在 `127.0.0.1:port` 起一个 `pmc::net::HttpServer`(纯 HTTP,无 TLS,因为 WAF 已终止 TLS),注册 `/list` `/exec` `/kill`。 +- **`ConfsTask`**(`ConfsTask.cpp`):起 `pmc::net::RpcServer`(pymsg),提供配置分发。 + +三者并列加入 `taskPool`,统一由主线程启停。 + +## 4. 进程托管核心(`PPool` / `ProcessTask` / `pmc_mtd`) + +- `PPool`:`std::list>`,接口 `crtp()`(create process)/ `clr()`(清理已退出)/ `kill(idx)` / `operator[]`。**注意 kill/list 用容器内下标而非 pid,代码内有 FIXME 标注不自然。** +- `ProcessTask`:`boost::process::child` 封装,`start()` 直接 `make_unique(cmd)`,`stop()` 调 `terminate()`。当前编译走非 `__NEW_VER`(boost.process v1)分支。 +- `pmc_mtd` 命名空间(`pmc_mtd.cpp`): + - `list()` → 序列化所有子进程 `{index, pid, running, command}`。 + - `kill(pid)` → 按 pid 遍历 `ppool` 匹配后 `kill(i)`。 + - `exec(cmd)` → `ppool.crtp(cmd)`。 + - `parse_self_init_list(path)` → 逐行读 `init.txt`,去掉 `#` 注释与空行,`ppool.clr()` 后 `crtp(row)`。**启动顺序很重要**(redirect80 → waf → ca → gateway …)。 + +## 5. 进程间通信层(`include/tt/`) + +跨进程 / 跨机的进程管理采用「消息队列发令 + 命名管道回传」: + +- **`Mq`**(`tt/Mq.h`):`boost::interprocess::message_queue`,名字 `/`,容量 2048×100。发送端 `send(json)`;接收端 `try_receive`。 +- **`Pipe`**(`tt/Pipe.h`):FIFO 命名管道,路径 `/tmp/pst-`,`CREATOR` 负责 `mkfifo`/`unlink`。`openForRead(nonblock)` + `select` 超时(`waitForRead`)+ `readPipe`。 + +指令 JSON 格式:`{"pipe": "<回传管道名>", "type": "list"|"kill"|"exec", ...}`。 + +- 服务端:`parse_data_from_mq()` 收到 JSON → 按 `type` 调 `pmc_mtd::list/kill/exec` → 经 `Pipe` 写回结果。 +- 客户端:`subsys_call` 命名空间(`subsys_call.cpp`):`send_*_and_recv_pipe(target, ...)` 先建 `Mq(target)` 发令,再开 `Pipe` 等回传(1 秒超时,失败返回 `"ERR"`)。 + +`HttpTask` 的 `listProcesses/execCommand/killProcess` 在 `target` 非空时走 `subsys_call` 转发到目标机器的 pmc,为空则本机 `pmc_mtd`。 + +## 6. HTTP 管理接口(`HttpTask` + `process_http_server`) + +- 仅监听 `127.0.0.1:5200`,纯 HTTP(WAF 已终止 TLS,无证书)。 +- 三个 POST 路由:`/list` `/exec` `/kill`,返回 `{"success", "message", "data"}`。 +- **管理员鉴权**(`verify_admin_token`):每个操作都先调 `gateway /admin/verify`(gateway 是 Token 签发/验签唯一权威,RSA 验签 + is_admin + 过期检查),Authorization 头优先、回退 `q4_token` Cookie;gateway 地址由 `GATEWAY_HOST`/`GATEWAY_PORT` 配置(默认 `127.0.0.1:8443`)。gateway 不可用或异常一律 `fail-closed` 返回 401。 +- 命令执行(`exec`)与 pid 解析同时支持 query 参数与 JSON body。 + +## 7. 内嵌配置服务(`ConfsTask` + `ConfigStore`) + +`ConfsTask.cpp` 由 confs2 移植而来,pymsg RPC,协议与 Python confs 字节级兼容,消费方零改动。 + +- `ConfigStore`:线程安全(`mutex`),内存缓存 + `mtime` 变化自动重载;`get()` 在锁内 canonical 解析 + 前缀校验 + 存在性检查(无 TOCTOU);扩展名白名单(`.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`)+ 1 MiB 上限防路径遍历/DoS。 +- 路由(与 confs2 逐项对齐): + - `get`:取全文;带 `key` 时 INI 用 `Section.option` 取值(Section 大小写敏感、Option 不敏感)、JSON 用点路径取值。 + - `list`:列目录全部配置元信息(name/size/mtime/cached)。 + - `reload`:指定 name 重载,或省略 name 清空全部缓存。 + - `health`:status/config_dir/cached/uptime。 +- `ConfsLogC`(`ConfsLogC.hpp`):基于 `libmsg::AsyncQueue`,50 条/2 秒攒批异步写 `db:8005` 的 `Logs` 表,`DB_HOST`/`DB_PORT` 可配(默认 127.0.0.1:8005),fail-safe 静默丢弃;含日志注入防护(`sanitize_for_log` 过滤控制字符)。 + +## 8. 支撑组件 + +- `Logger`(`logs/Logger.hpp`):单例,控制台输出,支持 DEBUG/INFO/WARNING/ERROR/CRITICAL 级别(注释标注为 DeepSeek 创作,TODO 改为文件存储)。 +- `include/cn/`:中文化编程宏(`中文化.hpp` 等),提供 C++ 关键字中文别名。 +- 依赖库(Makefile):`libth` `libhttp`/`libhttpclient` `libmsg` `libcurl` + boost(`program_options`/`json`/`thread`/`filesystem`)+ `ssl`/`crypto`/`pthread`。 + +## 9. 已知待办与风险(源码内 FIXME / 设计取舍) + +1. **无子进程自动重启**:core 崩溃守护靠 systemd;单个业务进程退出后 core 不会拉起它(仅保留在 `ppool` 中标记非运行)。 +2. **`PPool` 用下标而非 pid 索引**:`kill(idx)` 语义不自然,FIXME 仍在。 +3. **内存泄漏 TODO**:`HttpTask`/`ConfsTask` 的 `start()` 获取资源后未确认释放(代码注释标注)。 +4. **`/tmp/pst-*` FIFO 残留**:`subsys_call` 异常路径可能留下管道文件。 +5. **管理接口强依赖 gateway**:`/admin/verify` 不可用时所有管理操作 fail-closed 拒绝(安全优先,但运维注意 gateway 必须在线)。 +6. **`exec` 命令注入面**:`pmc_mtd::exec` 直接 `boost::process::child(cmd)` 以 shell 执行,仅在已通过管理员鉴权后可用——务必保证鉴权不被绕过。 + +## 10. 上手要点 + +- 调整自启动服务/顺序:编辑 `init.txt`(注释用 `#`)。 +- 管理接口调用:必须携带 admin token(经 gateway 签发),例如带 `Authorization: Bearer ` 或 `q4_token` Cookie 访问 `:5200`。 +- 构建:`core/_set.sh`(会先创建 `configs → ../confs/configs` 符号链接,保证内嵌 confs 零迁移);`make clear && make` 后 `make install` 装入 systemd。 From 26c91c603601a611163e879a808f55206270631e Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 18:38:24 +0000 Subject: [PATCH 015/196] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20actpred=20?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E8=AF=84=E4=BC=B0=E6=8A=A5=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20INDEX.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INDEX.md | 165 ----------- ...tpred_effectiveness_evaluation_20260809.md | 265 ++++++++++++++++++ 2 files changed, 265 insertions(+), 165 deletions(-) delete mode 100644 docs/INDEX.md create mode 100644 docs/reports/actpred_effectiveness_evaluation_20260809.md diff --git a/docs/INDEX.md b/docs/INDEX.md deleted file mode 100644 index 723484e..0000000 --- a/docs/INDEX.md +++ /dev/null @@ -1,165 +0,0 @@ -# micro_servers 文档索引 - -所有项目文档的统一入口。 - -## 目录结构 - -``` -docs/ -├── INDEX.md # 本文件(文档总索引) -├── security/ # 安全审计与加固(29 篇) -├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(14 篇) -``` - ---- - -## 一、项目总览 - -| 文档 | 路径 | 说明 | -|------|------|------| -| 项目 README | [../README.md](../README.md) | 架构总览、服务说明、安装运行指南 | - ---- - -## 二、安全文档 (`security/`) - -### 全局审计与加固 - -| 文档 | 说明 | -|------|------| -| [DEPLOY_SECURITY_AUDIT_20260801.md](security/DEPLOY_SECURITY_AUDIT_20260801.md) | 部署配置安全审计(docker-compose、systemd、防火墙、TLS 等) | -| [DEPS_SECURITY_AUDIT_20260801.md](security/DEPS_SECURITY_AUDIT_20260801.md) | 依赖安全审计(第三方库版本、已知漏洞、许可证) | -| [SECURITY_HARDENING_PLAN_20260803.md](security/SECURITY_HARDENING_PLAN_20260803.md) | 安全加固计划(内网通信安全加固开发计划) | - -### 各模块安全审计报告 - -| 模块 | 文档 | 审计范围 | -|------|------|----------| -| waf | [waf_SECURITY_AUDIT_20260801.md](security/waf_SECURITY_AUDIT_20260801.md) | Go WAF 网关(TLS、coraza、反向代理) | -| gateway | [gateway_SECURITY_AUDIT.md](security/gateway_SECURITY_AUDIT.md) | C++20 应用网关安全审计合并文档(v0.8.0 三轮审计合并:初版/复审/增量复审;v0.9.0 纯转发,认证迁 ca) | -| ca | [ca_SECURITY_AUDIT.md](security/ca_SECURITY_AUDIT.md) | C++20 中央认证服务(Central Auth,v1.0.0),承接原 gateway 认证职责;含迁移接收项与 ca 相关开放项 | -| core | [core_SECURITY_AUDIT_20260801.md](security/core_SECURITY_AUDIT_20260801.md) | C++20 进程托管服务(PMC/Nidus) | -| db | [db_SECURITY_AUDIT_20260801.md](security/db_SECURITY_AUDIT_20260801.md) | Python 数据库服务(pymsg、SQLAlchemy、日志收集) | -| db2 | [db2_SECURITY_AUDIT_20260806.md](security/db2_SECURITY_AUDIT_20260806.md) | C++20 数据库 RPC 服务(db 的 C++ 重实现,SQLite C API) | -| pymsg | [pymsg_SECURITY_AUDIT_20260801.md](security/pymsg_SECURITY_AUDIT_20260801.md) | Python RPC 基础库(长度前缀帧、MsgClient/Server) | -| dnfs | [dnfs_SECURITY_AUDIT_20260801.md](security/dnfs_SECURITY_AUDIT_20260801.md) | Python 树状网络文件系统(**已废弃,生产用 dnfs2**;2026-08-10 复核为全量 16 项发现) | -| dnfs2 | [dnfs2_SECURITY_AUDIT_20260808.md](security/dnfs2_SECURITY_AUDIT_20260808.md) | C++20 树状网络文件系统(dnfs 重实现,**生产在跑**,评级 A) | -| static | [static_SECURITY_AUDIT_20260801.md](security/static_SECURITY_AUDIT_20260801.md) | Python 前端托管/文件服务 | -| fsys | [fsys_SECURITY_AUDIT_20260808.md](security/fsys_SECURITY_AUDIT_20260808.md) | Python 文件系统服务 | -| ai_review | [ai_review_SECURITY_AUDIT_20260801.md](security/ai_review_SECURITY_AUDIT_20260801.md) | Python AI 内容审核(DeepSeek + RAG) | -| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验)初版 + 0809 复审合并版 | -| smtp3 | [smtp3_SECURITY_AUDIT_20260809.md](security/smtp3_SECURITY_AUDIT_20260809.md) | C++20 邮件服务(smtp 重写),2 项 P0 + P1-2 + 全部 P3 当日已修复,仅剩 P1-1(TLS 校验)待修 | -| captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 | -| captcha2 | [captcha2_SECURITY_AUDIT_20260809.md](security/captcha2_SECURITY_AUDIT_20260809.md) | C++20 验证码服务(captcha 重写,FreeType + stb,含 P0 大小写缺陷发现) | -| confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 | -| libmsg | [libmsg_SECURITY_AUDIT_20260806.md](security/libmsg_SECURITY_AUDIT_20260806.md) | C++ RPC 基础库(pymsg 协议帧、MsgClient/Server、RpcClient/Server) | -| libhttp | [libhttp_SECURITY_AUDIT_20260803.md](security/libhttp_SECURITY_AUDIT_20260803.md) | C++ HTTP/WebSocket 库(请求/响应/WS/静态文件) | -| libsqlite3 | [libsqlite3_SECURITY_AUDIT_20260806.md](security/libsqlite3_SECURITY_AUDIT_20260806.md) | C++ SQLite3 封装(Header-only) | -| libth | [libth_SECURITY_AUDIT_20260804.md](security/libth_SECURITY_AUDIT_20260804.md) | C++ 线程库(Thread、Cv_wait、Fsm) | -| aibox | [aibox_SECURITY_AUDIT_20260801.md](security/aibox_SECURITY_AUDIT_20260801.md) | Python AI 智能体系统(WebSocket) | -| BotOps | [BotOps_SECURITY_AUDIT_20260801.md](security/BotOps_SECURITY_AUDIT_20260801.md) | BotOps 运维控制面板 | -| actpred | [actpred_SECURITY_AUDIT_20260808.md](security/actpred_SECURITY_AUDIT_20260808.md) | Python 动作预测模块(PyTorch 训练/推理、.mt 解析、ONNX 导出) | - ---- - -## 三、代码审查 (`reviews/`) - -| 文档 | 说明 | -|------|------| -| [core_CODE_REVIEW_LOG_20260730.md](reviews/core_CODE_REVIEW_LOG_20260730.md) | Core 模块代码审查日志(2026-07-30,自动化审查) | -| [DEPLOY_DEFECT_AUDIT_20260806.md](reviews/DEPLOY_DEFECT_AUDIT_20260806.md) | 部署缺陷审查(2026-08-06,首次原生部署实录,含修复记录) | -| [actpred_CODE_AUDIT_20260808.md](reviews/actpred_CODE_AUDIT_20260808.md) | actpred 模块代码审计(2026-08-08,两轮合并:第 1 轮 16 项 + 第 2 轮复审 11 项,共 27 项全部修复) | -| [bot4cj_CODE_AUDIT_20260808.md](reviews/bot4cj_CODE_AUDIT_20260808.md) | bot4cj 模块代码审计(2026-08-08,仓颉机器人硬件脚本解析器,15 项发现,待修复) | - ---- - -## 四、分析报告与开发规划 (`reports/`) - -| 文档 | 说明 | -|------|------| -| [blackbird_research_report.md](reports/blackbird_research_report.md) | blackbird 项目研究报告(安全架构分析、改进建议、里程碑规划) | -| [core_service_registry_dev_doc.md](reports/core_service_registry_dev_doc.md) | Core 模块注册中心功能扩展开发文档(服务注册/发现/健康守护/自动重启) | -| [auth_service_plan.md](reports/auth_service_plan.md) | 认证独立化(CA 模块)开发文档(waf→ca→gateway 前置拓扑;gateway 退 8445 纯转发不验签;下游/WAF/前端零改动) | -| [aibox_open_dev_plan.md](reports/aibox_open_dev_plan.md) | aibox 开放版开发计划(命令注入漏洞修复、安全沙箱方案) | -| [aibox_file_network_cli_refactor.md](reports/aibox_file_network_cli_refactor.md) | aibox 文件/网络操作命令行化重构计划 | -| [blackbird_promo_full.md](reports/blackbird_promo_full.md) | blackbird 项目完整版宣传文案 | -| [bot4cj_analysis_20260525_00.md](reports/bot4cj_analysis_20260525_00.md) | bot4cj 机器人模块分析报告(Markdown) | -| [libcrypt_CBC_deprecation_dev_doc.md](reports/libcrypt_CBC_deprecation_dev_doc.md) | libcrypt CBC 模式缺陷分析与 AES-GCM 迁移开发文档 | -| [bot4cj_analysis_20260525_00.html](reports/bot4cj_analysis_20260525_00.html) | bot4cj 机器人模块分析报告(HTML) | -| [actpred_pretrain_test_20260808.md](reports/actpred_pretrain_test_20260808.md) | actpred 预训练与推理测试报告(action_dim=18 变更、.mt 解析器、预训练、推理基准、NPU 调研) | -| [captcha_cpp_rewrite_dev_doc.md](reports/captcha_cpp_rewrite_dev_doc.md) | captcha 模块 C++20 重写开发方案(FreeType + stb_image_write 替代 Pillow,协议兼容零改动) | -| [smtp_cpp_rewrite_dev_doc.md](reports/smtp_cpp_rewrite_dev_doc.md) | smtp 模块 C++20 重写开发方案(SMTP_SSL 客户端手写,审计 7 项遗留一并在重写中修复,零新增系统依赖) | -| [libhttp_remote_addr_dev_doc.md](reports/libhttp_remote_addr_dev_doc.md) | libhttp 对端地址透传开发方案(X-Remote-Addr 可信头注入,非破坏;gateway trust_xff 配套,闭环审计 A4) | -| [confs2_cpp_rewrite_dev_doc.md](reports/confs2_cpp_rewrite_dev_doc.md) | confs 模块 C++20 重写开发方案(libmsg RpcServer 替代 pymsg,协议兼容零改动,审计 7 项遗留一并修复,零新增系统依赖,未来可合入 core 注册中心) | -| [fsys_public_frontend_research_20260810.md](reports/fsys_public_frontend_research_20260810.md) | fsys/public 前端研究报告(Vanilla 技术栈、HttpOnly Cookie 认证体系、dnfs/aibox/view/mgmt 页面分析、8 项问题与改进建议) | - ---- - -## 五、各模块 README(保留在原目录) - -各模块根目录的 `README.md` 保留在原位,方便 GitHub 直接浏览: - -### 核心服务 - -| 模块 | 路径 | 说明 | -|------|------|------| -| core | [../core/README.md](../core/README.md) | 进程托管服务(PMC/Nidus) | -| waf | [../waf/README.md](../waf/README.md) | WAF 网关(Go, coraza) | -| gateway | [../gateway/README.md](../gateway/README.md) | 应用层网关(C++20) | -| db | [../db/README.md](../db/README.md) | 通用数据库服务(pymsg) | -| db2 | [../db2/README.md](../db2/README.md) | C++20 数据库 RPC 服务(db 重实现) | -| confs | [../confs/README.md](../confs/README.md) | 统一配置服务 | -| static | [../static/README.md](../static/README.md) | 前端托管服务 | -| dnfs | [../dnfs/README.md](../dnfs/README.md) | 树状网络文件系统 | -| dnfs2 | [../dnfs2/README.md](../dnfs2/README.md) | C++20 树状菜单服务(dnfs 重实现) | -| ai_review | [../ai_review/README.md](../ai_review/README.md) | AI 内容审核 | -| captcha | [../captcha/README.md](../captcha/README.md) | 验证码服务 | -| smtp | [../smtp/README.md](../smtp/README.md) | 邮件服务 | -| redirect80 | [../redirect80/README.md](../redirect80/README.md) | HTTP 重定向服务 | - -### 共享库 - -| 库 | 路径 | 说明 | -|----|------|------| -| pymsg | [../pymsg/README.md](../pymsg/README.md) | Python RPC 基础库 | -| libhttp | [../libhttp/README.md](../libhttp/README.md) | C++ HTTP/WebSocket 库 | -| libcrypt | [../libcrypt/README.md](../libcrypt/README.md) | C++ 加密库 | -| libth | [../libth/README.md](../libth/README.md) | C++ 线程库 | -| libsqlite3 | [../libsqlite3/README.md](../libsqlite3/README.md) | C++ SQLite3 封装 | -| libmsg | [../libmsg/README.md](../libmsg/README.md) | C++ RPC 基础库(pymsg 协议 C++ 实现) | -| libtry | [../libtry/](../libtry/) | 嵌入式脚本解释器 | - -### 其他模块 - -| 模块 | 路径 | 说明 | -|------|------|------| -| aibox | [../aibox/README.md](../aibox/README.md) | AI 智能体系统 | -| bot4cj | [../bot4cj/README.md](../bot4cj/README.md) | 机器人硬件脚本解析器(仓颉) | -| BotOps | [../BotOps/README.md](../BotOps/README.md) | aibox 智能体 TUI 前端(Textual) | -| actpred | [../actpred/README.md](../actpred/README.md) | 动作预测神经网络(PyTorch) | -| lci | [../lci/README.md](../lci/README.md) | 嵌入式帧缓冲图形界面 | -| fsys | [../fsys/README.md](../fsys/README.md) | 文件系统服务 | -| tests | [../tests/blackbox/README.md](../tests/blackbox/README.md) | 黑盒测试套件 | - -### Robot 文档 (README.robot.md) - -| 模块 | 路径 | -|------|------| -| pymsg | [../pymsg/README.robot.md](../pymsg/README.robot.md) | -| libhttp | [../libhttp/README.robot.md](../libhttp/README.robot.md) | -| libtry | [../libtry/README.robot.md](../libtry/README.robot.md) | -| aibox | [../aibox/README.robot.md](../aibox/README.robot.md) | -| bot4cj | [../bot4cj/README.robot.md](../bot4cj/README.robot.md) | - ---- - -## 六、aibox 技能与任务文档 - -aibox 模块的技能提示词和任务模板属于运行时数据,保留在原目录: - -| 类型 | 路径 | -|------|------| -| 技能格式说明 | [../aibox/SKILLS_FORMAT.md](../aibox/SKILLS_FORMAT.md) | -| 技能目录 | [../aibox/skills/](../aibox/skills/) | -| 任务目录 | [../aibox/tasks/](../aibox/tasks/) | diff --git a/docs/reports/actpred_effectiveness_evaluation_20260809.md b/docs/reports/actpred_effectiveness_evaluation_20260809.md new file mode 100644 index 0000000..f2d04ae --- /dev/null +++ b/docs/reports/actpred_effectiveness_evaluation_20260809.md @@ -0,0 +1,265 @@ +# actpred 模块效果评估报告 + +> 日期:2026-08-09 +> 评估范围:actpred 模块架构、代码质量、预训练模型实际预测能力、部署就绪度 +> 评估方法:源码审阅 + 单元测试 + 预训练 checkpoint 全量滑动窗口定量评估 + +--- + +## 一、模块定位 + +actpred 是 micro_servers 的独立/边缘模块,纯离线 CLI 工具,不参与微服务调用链。 +核心功能:输入多帧摄像头图像 + 历史动作序列 → 预测下一时间步的连续动作(回归)。 +面向树莓派 Dashun 蜘蛛机器人(配合 bot4cj 模块),目标部署场景为嵌入式机器人实时控制。 + +--- + +## 二、架构评估 + +### 2.1 模型架构 + +多帧 Transformer 注意力融合(2026-08 重写): + +``` +F 帧图像 (F×3×64×64) → 共享 CNN 编码器 → F 个 token → proj → d_model ┐ + ├→ [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) → Linear proj → d_model → H 个 token ─┤ │ +可学习 [PRED] token ─────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2层, 4头, d=128) + │ + 取 PRED 位置输出 → MLP 头 → 预测动作 +``` + +### 2.2 关键指标 + +| 指标 | 数值 | +|------|------| +| 参数量 | ~450K | +| CPU 端到端推理 | 4.25 ms(235 FPS) | +| CPU 纯 forward | 2.77 ms | +| image_encoder 占比 | 47%(pure forward) | +| ONNX 导出 | opset 17,仅 batch 维动态 | +| ONNX 一致性 | max diff ~1.5e-8 | +| 单元测试 | 56 个,全部通过 | + +### 2.3 架构评价 + +| 维度 | 评级 | 说明 | +|------|------|------| +| 模型设计 | A | 多帧注意力融合合理,token 序列固定长度 F+H+1,NPU 友好 | +| 推理速度 | A | 4.25ms 端到端,满足实时控制需求(>100 FPS) | +| ONNX 部署 | A- | 静态 shape 导出正确,版本转换有轻微告警不影响功能 | +| 安全性 | A | 零网络攻击面 + weights_only + 完整配置校验链 | + +--- + +## 三、代码质量评估 + +### 3.1 代码规模 + +| 文件 | 行数 | 职责 | +|------|------|------| +| config.py | 205 | 三层 dataclass 配置 + CLI + 校验 | +| model.py | 130 | SimpleCNN / MobileNetV2 / Transformer 模型 | +| dataset.py | 151 | 数据集加载 + DataRecorder | +| train.py | 187 | 训练入口(episode 级划分) | +| pretrain_mt.py | 289 | .mt 预训练入口(文件级划分) | +| predict.py | 138 | 推理封装 + CLI | +| export_onnx.py | 58 | ONNX 导出 + 一致性验证 | +| mt_parser.py | 133 | .mt @Action 矩阵解析器 | +| test_actpred.py | 722 | 单元测试(56 cases) | +| **合计** | **2013** | | + +### 3.2 优点 + +1. **安全性设计扎实** + - `torch.load(..., weights_only=True)` 全局使用 + - `parse_checkpoint_config` 实现键集合 + 值域 + 整除性三重校验 + - 旧架构 checkpoint 被未知键检查拒载 + +2. **数据泄漏已修复** + - pretrain_mt.py 采用文件级 train/val 划分(非样本级滑动窗口) + - train.py 采用 episode 级划分,归一化统计量仅用训练集计算 + +3. **工程化成熟** + - Config dataclass 三层结构 + CLI override + checkpoint roundtrip + - DataRecorder 原子 mkdir 防并发竞态 + - mt_parser `-` 前向填充取 @Servo 初始角度 + +4. **测试覆盖全面** + - 56 单元测试,覆盖前向传播、数据加载、配置校验、mt 解析、推理端到端、ONNX 导出 + - 边界条件充分(空数据集、维度不匹配、帧数不一致、未知键、旧 checkpoint 拒载等) + +### 3.3 可改进项 + +1. image_encoder 仍为推理瓶颈(占 pure forward 的 47%),后续可 INT8 量化 +2. 训练仅用 CosineAnnealingLR,未配置 warmup +3. ONNX 导出 opset 17 版本转换有告警(`axes_input_to_attribute` 断言失败后 fallback),不影响正确性但值得跟进 +4. `requirements.txt` 版本范围宽松(`torch>=2.0,<3.0`),生产环境建议锁定 + +--- + +## 四、预训练模型定量评估 + +### 4.1 训练数据概况 + +``` +.mt 脚本文件数: 13 +总动作行数: 77 +训练数据列数: 12(补零到 18) +文件级划分后: ~10 文件训练 / ~3 文件验证 +训练样本数: ~50(滑动窗口) +模型参数量: 450K +样本/参数比: 1:9000(严重欠定) +``` + +各 .mt 文件数据量: + +| 文件 | 行数 | 动作范围 | +|------|------|----------| +| jump.mt | 5 | [-180, 200] | +| left-hand.mt | 4 | [-180, 1000] | +| left-turn.mt | 11 | [-180, 200] | +| lie-down.mt | 6 | [-180, 200] | +| rght-hand.mt | 4 | [-180, 1000] | +| std.mt | 1 | [-180, 200] | +| stdd.mt | 7 | [-180, 200] | +| t0.mt | 10 | [-180, 500] | +| t1.mt | 1 | [10, 20] | +| tilt-b/f/l/r.mt | 各 7 | [-180, 200] | + +### 4.2 全量滑动窗口评估 + +使用预训练 checkpoint `pretrain_best_inference.pt`,对所有 .mt 文件做 history_len=5 滑动窗口预测: + +| 脚本 | 预测数 | 平均 L2 误差 | +|------|--------|-------------| +| left-turn.mt | 6 | 117.9 | +| lie-down.mt | 1 | 107.8 | +| stdd.mt | 2 | 138.7 | +| t0.mt | 5 | 239.1 | +| tilt-b.mt | 2 | 193.9 | +| tilt-f.mt | 2 | 201.4 | +| tilt-l.mt | 2 | 210.9 | +| tilt-r.mt | 2 | 183.4 | + +### 4.3 与 Baseline 对比 + +| 方法 | Mean L2 Error | 相对比率 | +|------|---------------|----------| +| **模型预测** | **175.8** | **0.76x** | +| Naive(复制上一步) | 230.0 | 1.0x(基准) | +| 全局均值预测器 | 251.2 | 1.09x | + +模型比 "复制上一步" 好 24%,比 "全局均值" 好 30%。但评估集仅 22 个预测点,统计意义有限。 + +### 4.4 预测分布分析 + +| 指标 | 预测 | 实际 | +|------|------|------| +| 数值范围 | [-164.5, 176.3] | [-180.0, 500.0] | +| 各维 std(舵机 0-9) | 12~57 | 0~95 | +| 各维 std(扬声器/延时 10-11) | 4~58 | 4~139 | +| 各维 std(预留 12-17) | 1~6 | 0 | + +**核心问题**:预测严重趋于均值(regression to the mean),预测 std 仅为实际 std 的 ~50%。极端值(如扬声器频率 1000Hz、延时 200ms)完全无法捕获。 + +### 4.5 典型预测案例 + +left-turn.mt 滑动窗口预测(前 6 维): + +``` +t=5: pred=[18.0, 10.3, 17.1, -1.3, 16.5, 11.2] actual=[45, 0, 45, 45, 45, 0] L2=129.4 +t=6: pred=[16.2, 8.5, 15.5, -0.4, 15.1, 8.9] actual=[45, 0, 0, 45, 45, 0] L2=127.3 +t=7: pred=[15.7, 8.0, 15.0, -0.3, 14.7, 8.3] actual=[45, 0, 0, 0, 45, 0] L2=109.1 +``` + +预测值集中在 8~18 附近,而实际动作在 0~45 之间跳变,模型未能跟踪动作变化趋势。 + +--- + +## 五、分场景效果预测 + +### 场景 A:当前预训练模型直接部署 + +**评级:不可用** + +- image_encoder 在零图像上训练,真实摄像头输入产生随机特征 +- 预测值被压缩到均值附近 ±15°,无法执行跳变动作 +- 扬声器频率预测几乎为 0 +- 预期表现:蜘蛛机器人缓慢无规律蠕动,无法完成有意义动作 + +### 场景 B:初步采集真实数据微调(~1K 帧,1-2 episodes) + +**评级:有限可用** + +- 450K 参数在 1K 帧上可学到基本运动模式 +- 平滑动作(站立→缓走)MAE ~15-25°/关节 +- 急变动作(跳跃、急转)MAE ~40-60°/关节 +- image_encoder 开始学习真实视觉特征 + +### 场景 C:充分数据采集(~10K 帧,10+ episodes,多场景) + +**评级:基本可用** + +- 平滑动作 MAE ~5-15°/关节 +- 急变动作 MAE ~20-40°/关节 +- 配合 noise_std 数据增强和早停,泛化可接受 +- SimpleCNN 表达力上限开始制约视觉理解 + +### 场景 D:场景 C + MobileNetV2 + 归一化动作 + +**评级:较好** + +- MobileNetV2 ImageNet 预训练权重提供强视觉先验 +- 归一化动作空间让 MSE loss 对各维度公平 +- 平滑动作 MAE ~3-8°/关节 + +### 场景 E:理想状态(~100K 帧,充分多样性) + +**评级:生产可用** + +- MAE ~1-5°/关节 +- 可完成绝大部分预设动作 + +--- + +## 六、核心瓶颈排序 + +| 优先级 | 瓶颈 | 严重度 | 说明 | +|--------|------|--------|------| +| 1 | 数据量 | 致命 | 77 行 / 13 文件,450K 参数 vs ~50 样本,样本/参数比 1:9000 | +| 2 | 零图像预训练的 image_encoder | 严重 | 47% 推理算力浪费在未训练的随机 CNN 上 | +| 3 | 动作空间异质性 | 中等 | dim 0-9 角度 [0-200°]、dim 10-11 频率/延时 [0-1000]、dim 12-17 全零,未归一化导致 MSE 被大数值维度主导 | +| 4 | 编舞脚本 vs 反应式行为 | 中等 | .mt 是预设动作编排,不含视觉反馈逻辑,模型学到的是 "动作惯性" 而非 "视觉→动作" 映射 | +| 5 | SimpleCNN 表达力 | 轻微 | 4 层 Conv 对细粒度空间信息编码不足 | + +--- + +## 七、量化预测总结 + +| 阶段 | 数据量 | 可用度 | 舵机 MAE | 能否完成预设动作 | +|------|--------|--------|----------|-----------------| +| 当前预训练 | 77 行 / 零图像 | 不可用 | >50° | 不能 | +| 初步采集 | ~1K 帧 | 有限 | 15-40° | 部分(简单动作) | +| 充分采集 | ~10K 帧 | 基本可用 | 5-20° | 大部分 | +| 充分 + MobileNet | ~10K 帧 | 较好 | 3-10° | 大部分 | +| 理想状态 | ~100K 帧 | 生产可用 | 1-5° | 绝大部分 | + +--- + +## 八、建议优先级 + +1. **最高优先**:实现遥操作/示教采集,尽快积累真实 (图像, 动作) 配对数据 +2. **高优先**:采集前开启 `--normalize-actions`,平衡各维度 loss 权重 +3. **中优先**:用真实数据微调后再评估,决定是否换 backbone +4. **低优先**:考虑预训练时冻结 image_encoder(减少无效梯度),或换恒等映射替代零图像 + +--- + +## 九、结论 + +actpred 模块架构设计合理,代码质量高,安全防护到位,推理速度满足实时需求。 +当前最大短板是 **数据量严重不足**(77 行零图像预训练),导致模型预测趋于均值、直接部署不可用。 +采集 10K 帧以上真实数据微调后,预计可达实用水平。 +模块已具备从 "实验原型" 向 "生产部署" 过渡的架构基础,下一步关键路径是 **数据采集 → 微调验证 → NPU 部署测试**。 From bba7ee3eb4e01ee6ff3f6c86d0e87c16569c700c Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 18:54:03 +0000 Subject: [PATCH 016/196] =?UTF-8?q?feat(pybot):=20bot4cj=20=E7=9A=84=20Pyt?= =?UTF-8?q?hon=20=E8=BF=81=E7=A7=BB=E7=89=88=EF=BC=88.mt=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20+=20=E7=A1=AC=E4=BB=B6=E9=A9=B1=E5=8A=A8=20+=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parser: FSM 解析器与仓颉版逐语义对齐,修 S01/S02/S04/Q05 - robot/hardvars: 六类硬件变量工厂,修 r.cj:911 重名 FIXME、Q07 倒序释放 - plugin: Python 插件替代 Lua 桥接,修 S07 三级路径查找 - hd: i2c(每fd锁 Q03/S06)/spi/servo/kalman/mpu6050(S05/Q06)/speaker(S03 实声) - modules: PCA9685 与 Lua 版逐字节对齐;ADS1115 重写(experimental);ADS1256 空桩 - tests: 42 项 pytest 全绿(Mock 总线 journal 断言) - CLI: --dry-run / --test roundtrip / --verbose;运行时零第三方依赖 --- pybot/.gitignore | 5 + pybot/README.md | 57 ++++++++ pybot/_run.sh | 5 + pybot/_set.sh | 10 ++ pybot/modules/ADS1115.py | 56 ++++++++ pybot/modules/ADS1256.py | 24 ++++ pybot/modules/PCA9685.py | 72 +++++++++++ pybot/modules/SPI.py | 42 ++++++ pybot/pybot/__init__.py | 27 ++++ pybot/pybot/__main__.py | 7 + pybot/pybot/hardvars.py | 183 ++++++++++++++++++++++++++ pybot/pybot/hd/__init__.py | 1 + pybot/pybot/hd/i2c.py | 219 +++++++++++++++++++++++++++++++ pybot/pybot/hd/kalman.py | 29 +++++ pybot/pybot/hd/mpu6050.py | 134 +++++++++++++++++++ pybot/pybot/hd/servo.py | 29 +++++ pybot/pybot/hd/speaker.py | 98 ++++++++++++++ pybot/pybot/hd/spi.py | 120 +++++++++++++++++ pybot/pybot/main.py | 104 +++++++++++++++ pybot/pybot/parser.py | 243 +++++++++++++++++++++++++++++++++++ pybot/pybot/plugin.py | 102 +++++++++++++++ pybot/pybot/robot.py | 177 +++++++++++++++++++++++++ pybot/requirements.txt | 3 + pybot/scripts | 1 + pybot/tests/__init__.py | 0 pybot/tests/conftest.py | 30 +++++ pybot/tests/test_hardvars.py | 121 +++++++++++++++++ pybot/tests/test_mpu6050.py | 48 +++++++ pybot/tests/test_parser.py | 112 ++++++++++++++++ pybot/tests/test_plugin.py | 49 +++++++ pybot/tests/test_servo.py | 47 +++++++ 31 files changed, 2155 insertions(+) create mode 100644 pybot/.gitignore create mode 100644 pybot/README.md create mode 100755 pybot/_run.sh create mode 100755 pybot/_set.sh create mode 100644 pybot/modules/ADS1115.py create mode 100644 pybot/modules/ADS1256.py create mode 100644 pybot/modules/PCA9685.py create mode 100644 pybot/modules/SPI.py create mode 100644 pybot/pybot/__init__.py create mode 100644 pybot/pybot/__main__.py create mode 100644 pybot/pybot/hardvars.py create mode 100644 pybot/pybot/hd/__init__.py create mode 100644 pybot/pybot/hd/i2c.py create mode 100644 pybot/pybot/hd/kalman.py create mode 100644 pybot/pybot/hd/mpu6050.py create mode 100644 pybot/pybot/hd/servo.py create mode 100644 pybot/pybot/hd/speaker.py create mode 100644 pybot/pybot/hd/spi.py create mode 100644 pybot/pybot/main.py create mode 100644 pybot/pybot/parser.py create mode 100644 pybot/pybot/plugin.py create mode 100644 pybot/pybot/robot.py create mode 100644 pybot/requirements.txt create mode 120000 pybot/scripts create mode 100644 pybot/tests/__init__.py create mode 100644 pybot/tests/conftest.py create mode 100644 pybot/tests/test_hardvars.py create mode 100644 pybot/tests/test_mpu6050.py create mode 100644 pybot/tests/test_parser.py create mode 100644 pybot/tests/test_plugin.py create mode 100644 pybot/tests/test_servo.py diff --git a/pybot/.gitignore b/pybot/.gitignore new file mode 100644 index 0000000..f487a21 --- /dev/null +++ b/pybot/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +venv/ +.pytest_cache/ +/tmp*.mt diff --git a/pybot/README.md b/pybot/README.md new file mode 100644 index 0000000..985b8f2 --- /dev/null +++ b/pybot/README.md @@ -0,0 +1,57 @@ +# pybot + +机器人 `.mt` 动作脚本解析器 —— [bot4cj](../bot4cj/)(仓颉版)的 Python 迁移版。 + +驱动大顺(Dashun)蜘蛛机器人(树莓派 aarch64,10 舵机 + 2 云台, +PCA9685 PWM 控制器 + MPU6050 姿态传感器)。 + +迁移方案与审计修复映射见 +[docs/reports/bot4cj_python_migration_dev_doc.md](../docs/reports/bot4cj_python_migration_dev_doc.md)。 + +## 特性 + +- **零第三方依赖**:纯标准库(fcntl / struct / threading / wave / argparse), + 树莓派上 `git pull` 即部署,无构建步骤。 +- **与仓颉版逐语义对齐**:FSM 解析器、占位符递归、Servo 校准算法、 + PCA9685 寄存器序列均与 bot4cj 一致(Mock 总线 journal 可逐字节 diff)。 +- **Python 插件替代 Lua**:`modules/<块名>.py` 定义 + `build(ctx) / call(ctx) / destroy(ctx)`,消除 JSON 深拷贝 + 五层 FFI 桥接。 +- **审计修复**:bot4cj 审计 15 项发现(S01~S08 / Q01~Q07)全部修复, + 详见迁移文档 §5。 + +## 用法 + +```bash +cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法 +./_run.sh scripts/std.mt # 位置参数(增强) +./_run.sh --dry-run scripts/std.mt # Mock 总线,动作序列到 stdout +./_run.sh --test roundtrip in.mt out.mt # 解析↔序列化往返自检 +./_run.sh --verbose scripts/std.mt # DEBUG 日志(stderr) +``` + +`-i` / `-o` 参数位保留(reserved),未来对接 [actpred](../actpred/) 动作预测。 + +## 目录 + +``` +pybot/ # 包:parser / robot / hardvars / plugin / main +pybot/hd/ # 硬件层:i2c / spi / servo / kalman / mpu6050 / speaker +modules/ # 硬件驱动插件:PCA9685 / SPI / ADS1115 / ADS1256 +scripts -> ../bot4cj/scripts # .mt 脚本与仓颉版共享 +tests/ # pytest 单元测试(Mock 总线,无需硬件) +``` + +插件查找顺序:`$PYBOT_MODULES_PATH`(冒号分隔)→ 包内置 `modules/` → `./modules/`。 + +## 开发 + +```bash +./_set.sh # venv + pytest 全量测试 +venv/bin/python -m pytest tests/ -v +``` + +## .mt 脚本格式 + +见 [bot4cj/README.md](../bot4cj/README.md) 与 `scripts/std.mt`。 +格式要点:`@块名` 开块、行 0 为表头、`;` 结行、`#` 注释、 +`-` 占位符(非首行取上一行同列值;首行数值列解析为 0)、`@` 收块。 diff --git a/pybot/_run.sh b/pybot/_run.sh new file mode 100755 index 0000000..39aa920 --- /dev/null +++ b/pybot/_run.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# 与 bot4cj 用法一致:cat scripts/std.mt | ./_run.sh +# 零第三方依赖(纯标准库),直接用系统 python3,无需 venv +cd "$(dirname "$0")" || exit +exec python3 -m pybot "$@" diff --git a/pybot/_set.sh b/pybot/_set.sh new file mode 100755 index 0000000..ca40a60 --- /dev/null +++ b/pybot/_set.sh @@ -0,0 +1,10 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit + +# 运行时零第三方依赖(纯标准库);venv 仅为开发测试(pytest) +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 运行测试 +exec python3 -m pytest tests/ -v diff --git a/pybot/modules/ADS1115.py b/pybot/modules/ADS1115.py new file mode 100644 index 0000000..0db2d07 --- /dev/null +++ b/pybot/modules/ADS1115.py @@ -0,0 +1,56 @@ +"""ADS1115 16 位 I2C ADC 插件(experimental) + +⚠️ bot4cj/modules/ADS1115.lua 是 PCA9685 的复制残片(引用未定义的 +prescale、写的是 PCA9685 寄存器,运行必错),无忠实移植价值。 +本文件按 ADS1115 数据手册写干净的最小单次转换实现,未经实机验证。 + +ctx 元数据:name / fd(I2C 引用展开)/ address(如 "0x48") +call 实时参数:channel(0~3,单端输入 AINx) +call 返回:{"raw": 有符号 16 位原始值, "voltage": 电压(±4.096V 量程)} +""" + +import logging +import struct +import time + +from pybot.hd import i2c + +log = logging.getLogger("pybot.modules.ADS1115") + +REG_CONVERSION = 0x00 +REG_CONFIG = 0x01 + +# 单次转换配置:OS=1(开始) | MUX=100+ch(单端) | PGA=001(±4.096V) | MODE=1(单次) +# 低字节:DR=100(128SPS) | 比较器禁用(11) +_CONFIG_HI_BASE = 0b1_100_001_1 # channel 0,其余通道 MUX +ch +_CONFIG_LO = 0b100_0_0_0_11 +FSR = 4.096 # 满量程 ±4.096V + + +def build(ctx): + """无需初始化(单次转换模式按需触发),仅校验设备可达""" + fd = int(ctx["fd"]) + addr = int(str(ctx["address"]), 0) + log.info("ADS1115 build (experimental): fd=%d addr=0x%02X", fd, addr) + return {"result": "OK"} + + +def call(ctx): + """触发一次单次转换并读回结果""" + fd = int(ctx["fd"]) + addr = int(str(ctx["address"]), 0) + channel = int(ctx.get("channel", 0)) & 0x03 + config_hi = _CONFIG_HI_BASE + (channel << 4) + with i2c.transaction(fd): + i2c.set_addr(fd, addr) + i2c.write_block(fd, REG_CONFIG, bytes([config_hi, _CONFIG_LO])) + time.sleep(0.009) # 128SPS 一次转换约 8ms + data = i2c.read_block(fd, REG_CONVERSION, 2) + raw = struct.unpack(">h", data)[0] # 大端有符号 16 位 + return {"result": "OK", "raw": raw, "voltage": raw * FSR / 32768.0} + + +def destroy(ctx): + """单次转换模式空闲即低功耗,无需复位""" + log.info("ADS1115 destroy") + return {"result": "OK"} diff --git a/pybot/modules/ADS1256.py b/pybot/modules/ADS1256.py new file mode 100644 index 0000000..50b200e --- /dev/null +++ b/pybot/modules/ADS1256.py @@ -0,0 +1,24 @@ +"""ADS1256 24 位 SPI ADC 插件 —— 对应 bot4cj/modules/ADS1256.lua(空桩) + +Lua 版即为空桩(无任何实现),保持同等状态:三个入口仅记日志。 +实机接入时参考 modules/SPI.py 取 fd 后经 pybot.hd.spi.transfer 实现。 +""" + +import logging + +log = logging.getLogger("pybot.modules.ADS1256") + + +def build(ctx): + log.warning("ADS1256 is a stub (not implemented), build ctx: %s", ctx) + return {"result": "OK", "status": "stub"} + + +def call(ctx): + log.warning("ADS1256 is a stub (not implemented)") + return {"result": "OK", "status": "stub"} + + +def destroy(ctx): + log.warning("ADS1256 is a stub (not implemented)") + return {"result": "OK", "status": "stub"} diff --git a/pybot/modules/PCA9685.py b/pybot/modules/PCA9685.py new file mode 100644 index 0000000..4f97f6a --- /dev/null +++ b/pybot/modules/PCA9685.py @@ -0,0 +1,72 @@ +"""PCA9685 16 路 PWM 舵机控制器插件 —— 与 bot4cj/modules/PCA9685.lua 逐语义对齐 + +pybot 插件协议:build(ctx) / call(ctx) / destroy(ctx) +ctx 元数据来自 @PCA9685 块: + name / fd(I2C 引用展开)/ address(原始字符串如 "0x40") +call 实时参数:channel(int)/ angle(int,0~180) +""" + +import logging +import time + +from pybot.hd import i2c + +log = logging.getLogger("pybot.modules.PCA9685") + +# MODE1 寄存器(0x00)控制位 +MODE1 = 0x00 +MODE1_RESTART = 0x80 +MODE1_SLEEP = 0x10 +MODE1_AUTOINC = 0x20 +PRE_SCALE = 0xFE +LED0_ON_L = 0x06 # 通道 0 PWM 寄存器组起点,每通道 4 个寄存器 + +# 50Hz 预分频:25MHz / (4096 * 50Hz) - 1,四舍五入 = 121(与 Lua 版一致) +PRESCALE_50HZ = int(25000000.0 / (4096.0 * 50.0) - 1.0 + 0.5) + + +def _fd_addr(ctx): + return int(ctx["fd"]), int(str(ctx["address"]), 0) + + +def build(ctx): + """初始化:重启 → SLEEP → 设 prescale(50Hz) → 唤醒(寄存器序列与 Lua 版一致)""" + fd, addr = _fd_addr(ctx) + log.info("PCA9685 build: fd=%d addr=0x%02X prescale=%d", + fd, addr, PRESCALE_50HZ) + with i2c.transaction(fd): + i2c.set_addr(fd, addr) + i2c.write_reg(fd, MODE1, MODE1_RESTART) # 复位 + time.sleep(0.005) + i2c.write_reg(fd, MODE1, MODE1_SLEEP) # 休眠(改 prescale 前提) + i2c.write_reg(fd, PRE_SCALE, PRESCALE_50HZ) # 50Hz + i2c.write_reg(fd, MODE1, MODE1_AUTOINC) # 唤醒 + 自动递增 + time.sleep(0.005) + return {"result": "OK"} + + +def call(ctx): + """写通道 PWM:0.5ms(0°) ~ 2.5ms(180°) 脉宽 @ 50Hz(20ms 周期)""" + fd, addr = _fd_addr(ctx) + channel = int(ctx["channel"]) + angle = float(ctx["angle"]) + width = 0.5 + 2.0 * angle / 180.0 # 脉宽 ms + offval = int(width * 4096.0 / 20.0) # 20ms 周期内的计数(floor) + reg = LED0_ON_L + 4 * channel + with i2c.transaction(fd): + i2c.set_addr(fd, addr) + i2c.write_reg(fd, reg + 0, 0x00) # ON_L + i2c.write_reg(fd, reg + 1, 0x00) # ON_H + i2c.write_reg(fd, reg + 2, offval & 0xFF) # OFF_L + i2c.write_reg(fd, reg + 3, (offval >> 8) & 0xFF) # OFF_H + return {"result": "OK"} + + +def destroy(ctx): + """复位 PCA9685""" + fd, addr = _fd_addr(ctx) + log.info("PCA9685 destroy: fd=%d addr=0x%02X", fd, addr) + with i2c.transaction(fd): + i2c.set_addr(fd, addr) + i2c.write_reg(fd, MODE1, MODE1_RESTART) + return {"result": "OK"} diff --git a/pybot/modules/SPI.py b/pybot/modules/SPI.py new file mode 100644 index 0000000..73b31c3 --- /dev/null +++ b/pybot/modules/SPI.py @@ -0,0 +1,42 @@ +"""SPI 总线插件 —— 与 bot4cj/modules/SPI.lua 逐语义对齐 + +@SPI 块元数据:name / path / spi_mode / bits / speed / delay +build 打开并配置 spidev;call 返回 {"fd": fd}(供其他块引用展开); +destroy 关闭。模块级全局 _fd 即设备私有状态 +(pybot 每设备加载独立模块实例,与 Lua 每设备独立状态机等价)。 +""" + +import logging + +from pybot.hd import spi + +log = logging.getLogger("pybot.modules.SPI") + +_fd = -1 # 全局文件句柄(对应 SPI.lua 的 fd) + + +def build(ctx): + global _fd + for key in ("path", "spi_mode", "bits", "speed", "delay"): + if key not in ctx: + return {"result": "ERR", "message": f"Invalid Arguments: {key}"} + log.info("SPI build parameters: %s", ctx) + _fd = spi.open_dev(ctx["path"]) + if _fd <= 0: + return {"result": "ERR", "message": "Failed to open SPI file"} + spi.setup(_fd, int(str(ctx["spi_mode"]), 0), + int(ctx["bits"]), int(ctx["speed"])) + return {"result": "OK", "fd": _fd} + + +def call(ctx): + return {"fd": _fd} + + +def destroy(ctx): + global _fd + log.info("SPI destroy, fd=%d", _fd) + if _fd > 0: + spi.close_dev(_fd) + _fd = -1 + return {"result": "OK"} diff --git a/pybot/pybot/__init__.py b/pybot/pybot/__init__.py new file mode 100644 index 0000000..d732b84 --- /dev/null +++ b/pybot/pybot/__init__.py @@ -0,0 +1,27 @@ +"""pybot —— 机器人 .mt 动作脚本解析器(bot4cj 的 Python 迁移版) + +架构与 bot4cj(仓颉版)逐语义对齐: + .mt 脚本 → parser.MatrixParser → MatrixObject 列表 + → robot.RobotStruct(按 @块名分发硬件变量工厂) + → active() / run() / deactive() + +硬件驱动插件(modules/.py)替代仓颉版的 Lua 扩展层, +协议为 build(ctx) / call(ctx) / destroy(ctx) 三段生命周期。 + +迁移方案见 docs/reports/bot4cj_python_migration_dev_doc.md。 +""" + +__version__ = "0.1.0" + +from .parser import ( # noqa: F401 + MatrixObject, + MatrixParser, + MatrixSaver, + ParseError, + DoesNotSync, + MatrixIndexError, + get_mt, + parse_mt, + to_int, +) +from .robot import RobotStruct # noqa: F401 diff --git a/pybot/pybot/__main__.py b/pybot/pybot/__main__.py new file mode 100644 index 0000000..8f35da2 --- /dev/null +++ b/pybot/pybot/__main__.py @@ -0,0 +1,7 @@ +"""python3 -m pybot 入口""" + +import sys + +from .main import main + +sys.exit(main()) diff --git a/pybot/pybot/hardvars.py b/pybot/pybot/hardvars.py new file mode 100644 index 0000000..b25a31f --- /dev/null +++ b/pybot/pybot/hardvars.py @@ -0,0 +1,183 @@ +"""硬件变量 —— 移植自 bot4cj/src/r.cj 的 MatrixVars_Hardware 及六个工厂 + +仓颉版三闭包 rise/things/free 改为基类三方法 build()/play(params)/destroy(), +JsonValue 通信协议改为原生 dict/str(无跨语言边界后无需序列化)。 + +每个子类提供 from_matrix(matrix, i, M, **kwargs) 工厂: + matrix: MatrixObject(行 0 是表头) + i: 数据行号(从 1 开始) + M: 已创建的硬件变量字典(供引用其他硬件,如 Servo→controller) +""" + +import logging + +from .hd import i2c as hd_i2c +from .hd.mpu6050 import MPU6050 +from .hd.servo import Servo +from .hd.speaker import Speaker +from .parser import get_mt, parse_mt, to_int + +log = logging.getLogger("pybot.hardvars") + + +class HardwareVar: + """硬件变量基类(对应仓颉版 MatrixVars_Hardware) + + build() —— 激活(对应闭包 rise) + play(params) —— 调用(对应闭包 things),入参/返回 dict(Alias 返回 str) + destroy() —— 释放(对应闭包 free),要求幂等 + """ + + def __init__(self, name): + self.name = name + + def build(self): + pass + + def play(self, params): + return {} + + def destroy(self): + pass + + +# ---------------- Alias 别名变量 ---------------- + +class AliasVar(HardwareVar): + """参数别名:@Action 列名 `s1.A` 中的 A → angle;play() 返回真实参数名 str""" + + def __init__(self, name, alias): + super().__init__(name) + self.alias = alias + + def play(self, params): + return self.alias + + @classmethod + def from_matrix(cls, matrix, i, M, **kwargs): + name = matrix[i, matrix.index_of_col(0, "name")] + alias = get_mt(matrix, i, matrix.index_of_col(0, "alias")) + log.info("Create a Alias hard-Var: %s -> %s", name, alias) + return cls(name, alias) + + +# ---------------- I2C 总线变量 ---------------- + +class I2CVar(HardwareVar): + """I2C 总线:工厂阶段即打开设备(与仓颉版一致);play() 返回 {"fd": fd}""" + + def __init__(self, name, path): + super().__init__(name) + self.bus = hd_i2c.I2CBus(path) + + def play(self, params): + return {"fd": self.bus.fd} + + def destroy(self): + log.info("I2C HVar: %s is closing now", self.name) + self.bus.close() # close 幂等 + + @classmethod + def from_matrix(cls, matrix, i, M, **kwargs): + name = matrix[i, matrix.index_of_col(0, "name")] + path = get_mt(matrix, i, matrix.index_of_col(0, "path")) + log.info("Create a I2C hard-Var: %s, path: %s", name, path) + return cls(name, path) + + +# ---------------- MPU6050_BIND 传感器变量 ---------------- + +class MPU6050Var(HardwareVar): + """MPU6050 绑定:build 启动采样线程,destroy 停止;play() 返回最近姿态""" + + def __init__(self, name, sensor): + super().__init__(name) + self.sensor = sensor + + def build(self): + log.info("MPU6050_BIND HVar: %s is starting now", self.name) + self.sensor.setup() + self.sensor.start_daemon() + + def play(self, params): + return self.sensor.latest() + + def destroy(self): + log.info("MPU6050_BIND HVar: %s is closing now", self.name) + self.sensor.stop_daemon() + self.sensor.wait_daemon() + + @classmethod + def from_matrix(cls, matrix, i, M, **kwargs): + name = matrix[i, matrix.index_of_col(0, "name")] + i2c_name = get_mt(matrix, i, matrix.index_of_col(0, "I2C")) + addr = get_mt(matrix, i, matrix.index_of_col(0, "addr")) + log.info("Create a MPU6050_BIND HVar: %s, use i2c interf: %s, " + "i2c addr: %s", name, i2c_name, addr) + fd = M[i2c_name].play({})["fd"] # 引用 I2C 变量取 fd + return cls(name, MPU6050(fd, to_int(addr))) + + +# ---------------- Servo 舵机变量 ---------------- + +class ServoVar(HardwareVar): + """舵机:play({"angle": x}) 经校准算法后回调 controller(如 PCA9685 插件)""" + + def __init__(self, name, servo): + super().__init__(name) + self.servo = servo + + def play(self, params): + self.servo.set_angle(float(params["angle"])) + return {"status": "OK"} + + @classmethod + def from_matrix(cls, matrix, i, M, **kwargs): + name = matrix[i, matrix.index_of_col(0, "name")] + max_a = parse_mt(matrix, i, matrix.index_of_col(0, "max")) + min_a = parse_mt(matrix, i, matrix.index_of_col(0, "min")) + offset = parse_mt(matrix, i, matrix.index_of_col(0, "offset")) + ctrl = get_mt(matrix, i, matrix.index_of_col(0, "controller")) + channel = parse_mt(matrix, i, matrix.index_of_col(0, "channel"), int) + log.info("Create a Servo hard-Var: %s, max: %s, min: %s, offset: %s, " + "controller: %s, channel: %s", + name, max_a, min_a, offset, ctrl, channel) + controller = M[ctrl] # like pca9685 + # int(angle) 截断对齐仓颉版 JsonInt(Int64(angle)) + servo = Servo(max_a, min_a, offset, + lambda angle: controller.play( + {"channel": channel, "angle": int(angle)})) + return cls(name, servo) + + +# ---------------- Speaker 扬声器变量 ---------------- + +class SpeakerVar(HardwareVar): + """扬声器:play({"frequency": f, "delay": ms}) 发声并阻塞 delay 毫秒""" + + def __init__(self, name, speaker): + super().__init__(name) + self.speaker = speaker + + def play(self, params): + freq = float(params["frequency"]) + delay = int(float(params["delay"])) # 对齐仓颉版 Int32(asFloat) + self.speaker.play(freq, delay) + return {"status": "OK"} + + @classmethod + def from_matrix(cls, matrix, i, M, dry_run=False, **kwargs): + name = matrix[i, matrix.index_of_col(0, "name")] + channels = parse_mt(matrix, i, matrix.index_of_col(0, "channels"), int) + sample_rate = parse_mt(matrix, i, + matrix.index_of_col(0, "sample_rate"), int) + volumn = parse_mt(matrix, i, matrix.index_of_col(0, "volumn")) + max_f = parse_mt(matrix, i, matrix.index_of_col(0, "max_f")) + min_f = parse_mt(matrix, i, matrix.index_of_col(0, "min_f")) + log.info("Create a Speaker: %s, channels: %s, sample_rate: %s, " + "volume: %s, max_f: %s, min_f: %s", + name, channels, sample_rate, volumn, max_f, min_f) + speaker = Speaker(channels=channels, sample_rate=sample_rate, + volumn=volumn, max_f=max_f, min_f=min_f, + dry_run=dry_run) + return cls(name, speaker) diff --git a/pybot/pybot/hd/__init__.py b/pybot/pybot/hd/__init__.py new file mode 100644 index 0000000..6472e7e --- /dev/null +++ b/pybot/pybot/hd/__init__.py @@ -0,0 +1 @@ +"""硬件层(Linux I2C / SPI / 舵机 / 传感器 / 扬声器)""" diff --git a/pybot/pybot/hd/i2c.py b/pybot/pybot/hd/i2c.py new file mode 100644 index 0000000..7db5b8a --- /dev/null +++ b/pybot/pybot/hd/i2c.py @@ -0,0 +1,219 @@ +"""Linux I2C 封装 —— 移植自 bot4cj/src/hd/I2C.cj + src/cpp/I2C.c + +纯标准库实现(fcntl.ioctl + os.read/write),零第三方依赖。 + +与仓颉版的差异(审计修复,语义不变): + S06: read_reg 返回 0..255 无符号 int(仓颉版返回有符号 Int8, + 16 位拼接时符号扩展破坏低字节); + Q03: 每 fd 锁用 dict[fd → threading.Lock](仓颉版 20 槽数组 O(n) 扫描且容量受限)。 + +后端可切换:默认真实总线;use_mock() 切换为记录型 Mock(--dry-run 与单测共用), +Mock 的写序列 journal 是与仓颉版做逐字节 diff 的等价性验证工件(方案 §6.3)。 +""" + +import fcntl +import logging +import os +import threading +from contextlib import contextmanager + +log = logging.getLogger("pybot.i2c") + +I2C_SLAVE = 0x0703 # at least on arm64(与仓颉版一致) + + +class I2CError(OSError): + """I2C 设备异常(对应仓颉版 I2cExcept 系列)""" + + +# ---------------- 后端(真实总线 / Mock) ---------------- + +class _RealBackend: + """真实 I2C 总线:glibc open/ioctl/read/write 一层到底""" + + def open(self, path): + return os.open(path, os.O_RDWR) + + def close(self, fd): + os.close(fd) + + def set_addr(self, fd, addr): + fcntl.ioctl(fd, I2C_SLAVE, addr) + + def write_reg(self, fd, reg, val): + if os.write(fd, bytes([reg & 0xFF, val & 0xFF])) != 2: + raise I2CError(f"failed to write i2c reg 0x{reg:02X} (fd={fd})") + + def read_reg(self, fd, reg): + # 读取寄存器之前要先写寄存器地址 + if os.write(fd, bytes([reg & 0xFF])) != 1: + raise I2CError(f"failed to select i2c reg 0x{reg:02X} (fd={fd})") + data = os.read(fd, 1) + if len(data) != 1: + raise I2CError(f"failed to read i2c reg 0x{reg:02X} (fd={fd})") + return data[0] # 0..255 无符号(修复 S06) + + def write_block(self, fd, reg, data): + """向 reg 起连写多字节(ADS1115 等 16 位寄存器设备)""" + buf = bytes([reg & 0xFF]) + bytes(data) + if os.write(fd, buf) != len(buf): + raise I2CError(f"failed to write i2c block at reg 0x{reg:02X} (fd={fd})") + + def read_block(self, fd, reg, n): + """从 reg 起连读 n 字节(自动递增寄存器,MPU6050 一次读 14 字节)""" + if os.write(fd, bytes([reg & 0xFF])) != 1: + raise I2CError(f"failed to select i2c reg 0x{reg:02X} (fd={fd})") + data = os.read(fd, n) + if len(data) != n: + raise I2CError(f"short read at i2c reg 0x{reg:02X}: " + f"{len(data)}/{n} bytes (fd={fd})") + return data + + +class MockBackend: + """记录型 Mock 总线:不碰硬件,记录全部操作序列供断言/diff""" + + def __init__(self): + self.journal = [] # [(op, ...), ...] 全部操作按序记录 + self.reg_values = {} # {(fd, reg): int} 单寄存器读注入 + self.block_values = {} # {(fd, reg): bytes} 块读注入 + self._next_fd = 1000 + + def open(self, path): + fd = self._next_fd + self._next_fd += 1 + self.journal.append(("open", path, fd)) + return fd + + def close(self, fd): + self.journal.append(("close", fd)) + + def set_addr(self, fd, addr): + self.journal.append(("set_addr", fd, addr)) + + def write_reg(self, fd, reg, val): + self.journal.append(("write_reg", fd, reg & 0xFF, val & 0xFF)) + + def read_reg(self, fd, reg): + self.journal.append(("read_reg", fd, reg)) + return self.reg_values.get((fd, reg), 0) & 0xFF + + def write_block(self, fd, reg, data): + self.journal.append(("write_block", fd, reg & 0xFF, bytes(data))) + + def read_block(self, fd, reg, n): + self.journal.append(("read_block", fd, reg, n)) + return self.block_values.get((fd, reg), bytes(n)) + + +_backend = _RealBackend() + + +def use_mock(): + """切换到 Mock 总线(--dry-run / 单测),返回 MockBackend 以便读取 journal""" + global _backend + _backend = MockBackend() + return _backend + + +def use_real(): + """切换回真实总线""" + global _backend + _backend = _RealBackend() + + +# ---------------- I2C 锁具(每 fd 一把,修复 Q03) ---------------- +# 同一 fd 上"设地址 + 读/写寄存器"必须是原子序列(多设备共享总线) + +_MAIN_LOCK = threading.Lock() # 主锁,操作锁具注册表时使用 +_LOCKS = {} # {fd: threading.Lock} + + +def _register_lock(fd): + with _MAIN_LOCK: + _LOCKS[fd] = threading.Lock() + + +def _unregister_lock(fd): + with _MAIN_LOCK: + _LOCKS.pop(fd, None) + + +@contextmanager +def transaction(fd): + """with transaction(fd): 持有该 fd 的总线锁,保证 I2C 操作序列原子""" + with _MAIN_LOCK: + lock = _LOCKS.get(fd) + if lock is None: + raise I2CError(f"no i2c lock for fd {fd} (device not opened by pybot?)") + with lock: + yield + + +# ---------------- 模块级函数(供插件 modules/*.py 直接调用) ---------------- +# 对应仓颉版注册给 Lua 的 i2c_open/i2c_close/i2c_set_addr/i2c_write_reg/i2c_read_reg + +def open_dev(path): + fd = _backend.open(path) + _register_lock(fd) + log.debug("i2c open %s -> fd=%d", path, fd) + return fd + + +def close_dev(fd): + _unregister_lock(fd) + _backend.close(fd) + log.debug("i2c close fd=%d", fd) + + +def set_addr(fd, addr): + _backend.set_addr(fd, addr) + + +def write_reg(fd, reg, val): + _backend.write_reg(fd, reg, val) + + +def read_reg(fd, reg): + return _backend.read_reg(fd, reg) + + +def write_block(fd, reg, data): + _backend.write_block(fd, reg, data) + + +def read_block(fd, reg, n): + return _backend.read_block(fd, reg, n) + + +# ---------------- I2C 总线对象(对应仓颉版 I2C_Orig) ---------------- + +class I2CBus: + """持有一个 I2C 设备文件句柄;close() 幂等""" + + def __init__(self, path): + self.path = path + self.fd = open_dev(path) + + def close(self): + if self.fd >= 0: + close_dev(self.fd) + self.fd = -1 + + def set_addr(self, addr): + set_addr(self.fd, addr) + + def write_reg(self, reg, val): + write_reg(self.fd, reg, val) + + def read_reg(self, reg): + return read_reg(self.fd, reg) + + def write_block(self, reg, data): + write_block(self.fd, reg, data) + + def read_block(self, reg, n): + return read_block(self.fd, reg, n) + + def transaction(self): + return transaction(self.fd) diff --git a/pybot/pybot/hd/kalman.py b/pybot/pybot/hd/kalman.py new file mode 100644 index 0000000..b3001a4 --- /dev/null +++ b/pybot/pybot/hd/kalman.py @@ -0,0 +1,29 @@ +"""一维卡尔曼滤波器 —— 移植自 bot4cj/src/math/kalman.cj + +用法: + k = Kalman(20.0, 200.0) # Q=20 R=200(与仓颉版 MPU6050 参数一致) + filtered = k.filter(sensor_value) + +R 固定时,Q 越大代表越信任测量值;Q 越小代表越信任模型预测值。 +""" + + +class Kalman: + """A=1 B=0 H=1 的一维卡尔曼滤波(五个核心公式)""" + + def __init__(self, q, r): + self.x_last = 0.0 # 上一时刻的最优结果 X(k-1|k-1) + self.p_last = 0.0 # 上一时刻最优结果的协方差 P(k-1|k-1) + self.a = 1.0 + self.q = q # 系统噪声协方差 + self.r = r # 测量噪声协方差 + + def filter(self, dat): + x_mid = self.a * self.x_last # (1) x(k|k-1) = A*X(k-1|k-1) + p_mid = self.a * self.p_last + self.q # (2) p(k|k-1) = A*p(k-1|k-1)*A'+Q + kg = p_mid / (p_mid + self.r) # (4) kg(k) = p(k|k-1)/(p(k|k-1)+R) + x_now = x_mid + kg * (dat - x_mid) # (3) x(k|k) = X(k|k-1)+kg*(Z(k)-X(k|k-1)) + p_now = (1.0 - kg) * p_mid # (5) p(k|k) = (1-kg)*P(k|k-1) + self.p_last = p_now + self.x_last = x_now + return x_now diff --git a/pybot/pybot/hd/mpu6050.py b/pybot/pybot/hd/mpu6050.py new file mode 100644 index 0000000..a5b40a2 --- /dev/null +++ b/pybot/pybot/hd/mpu6050.py @@ -0,0 +1,134 @@ +"""MPU6050 六轴姿态传感器 —— 移植自 bot4cj/src/hd/MPU6050.cj + +10ms 周期采样线程 + 6 路一维卡尔曼滤波,输出 roll/pitch 姿态角。 + +与仓颉版的差异(审计修复,语义不变): + S05: ADDR_1 = 0x69(AD0 拉高时的地址;仓颉版误写 0x68 与 ADDR_0 重复); + Q06: 停止采样用 threading.Event(仓颉版裸 bool 无内存屏障); + pitch 用 atan2(-ax, sqrt(ay²+az²)) 代替 atan(-ax/sqrt(...)),防除零且值域一致。 +""" + +import logging +import math +import struct +import threading +import time + +from . import i2c +from .kalman import Kalman + +log = logging.getLogger("pybot.mpu6050") + +ADDR_0 = 0x68 # AD0 引脚接地 +ADDR_1 = 0x69 # AD0 引脚拉高(修复 S05) + +# 寄存器地址 +PWR_MGMT_1 = 0x6B +GYRO_CONFIG = 0x1B +ACCEL_CONFIG = 0x1C +ACCEL_XOUT_H = 0x3B # 14 字节连读起点:加速度(6) + 温度(2) + 陀螺仪(6) + +RAD_TO_DEG = 57.2956 # 弧度转角度(与仓颉版一致) + +SAMPLE_INTERVAL = 0.01 # 10ms 采样周期 + + +class MPU6050: + """MPU6050 传感器:绑定到某条 I2C 总线的 fd 上 + + 用法: + m = MPU6050(fd, ADDR_0) + m.setup() + m.start_daemon() + ... m.latest() ... + m.stop_daemon(); m.wait_daemon() + """ + + def __init__(self, fd, addr=ADDR_0): + self.fd = fd + self.addr = addr + # 6 路卡尔曼滤波(Q=20 R=200,与仓颉版参数一致) + self._k_ax = Kalman(20.0, 200.0) + self._k_ay = Kalman(20.0, 200.0) + self._k_az = Kalman(20.0, 200.0) + self._k_gx = Kalman(20.0, 200.0) + self._k_gy = Kalman(20.0, 200.0) + self._k_gz = Kalman(20.0, 200.0) + self._stop = threading.Event() # 修复 Q06 + self._thread = None + self._lock = threading.Lock() + self._latest = {"roll": 0.0, "pitch": 0.0, + "ax": 0.0, "ay": 0.0, "az": 0.0, + "gx": 0.0, "gy": 0.0, "gz": 0.0} + + # ---------------- 初始化 ---------------- + + def setup(self): + """唤醒 + 配置量程:陀螺仪 ±500°/s、加速度 ±8g(与仓颉版一致)""" + with i2c.transaction(self.fd): + i2c.set_addr(self.fd, self.addr) + time.sleep(0.001) + i2c.write_reg(self.fd, PWR_MGMT_1, 0x00) # 解除休眠 + i2c.write_reg(self.fd, GYRO_CONFIG, 0x08) # ±500°/s + i2c.write_reg(self.fd, ACCEL_CONFIG, 0x10) # ±8g + time.sleep(0.1) # 等待传感器稳定(锁外,不占总线) + log.debug("mpu6050 setup done (fd=%d addr=0x%02X)", self.fd, self.addr) + + # ---------------- 读取 ---------------- + + def read_sensor(self): + """一次事务连读 14 字节,返回 (ax, ay, az, gx, gy, gz) 原始值""" + with i2c.transaction(self.fd): + i2c.set_addr(self.fd, self.addr) + data = i2c.read_block(self.fd, ACCEL_XOUT_H, 14) + vals = struct.unpack(">7h", data) # 大端 7 个有符号 16 位 + # 索引 3 是温度,跳过 + return vals[0], vals[1], vals[2], vals[4], vals[5], vals[6] + + def _sample_once(self): + ax, ay, az, gx, gy, gz = self.read_sensor() + fax = self._k_ax.filter(float(ax)) + fay = self._k_ay.filter(float(ay)) + faz = self._k_az.filter(float(az)) + fgx = self._k_gx.filter(float(gx)) + fgy = self._k_gy.filter(float(gy)) + fgz = self._k_gz.filter(float(gz)) + # 姿态角(加速度计解算,与仓颉版公式一致;pitch 用 atan2 防除零) + roll = math.atan2(fay, faz) * RAD_TO_DEG + pitch = math.atan2(-fax, math.sqrt(fay * fay + faz * faz)) * RAD_TO_DEG + with self._lock: + self._latest.update(roll=roll, pitch=pitch, + ax=fax, ay=fay, az=faz, + gx=fgx, gy=fgy, gz=fgz) + + def latest(self): + """最近一次滤波后的姿态数据(线程安全,返回副本)""" + with self._lock: + return dict(self._latest) + + # ---------------- 采样线程 ---------------- + + def start_daemon(self): + if self._thread is not None: + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, + name="mpu6050-sampler", daemon=True) + self._thread.start() + log.debug("mpu6050 sampler started") + + def _loop(self): + while not self._stop.wait(SAMPLE_INTERVAL): # 10ms 周期 + try: + self._sample_once() + except i2c.I2CError as e: + log.warning("mpu6050 sample failed: %s", e) + + def stop_daemon(self): + self._stop.set() + + def wait_daemon(self): + if self._thread is not None: + self._thread.join() + self._thread = None + log.debug("mpu6050 sampler stopped") diff --git a/pybot/pybot/hd/servo.py b/pybot/pybot/hd/servo.py new file mode 100644 index 0000000..52c6c69 --- /dev/null +++ b/pybot/pybot/hd/servo.py @@ -0,0 +1,29 @@ +"""舵机类 —— 移植自 bot4cj/src/hd/Servo.cj + +含最大值、最小值、漂移值三个调整项,以及一个设置角度回调函数。 +角度算法与仓颉版逐行对齐: + a = angle + offset + a = clamp(a, |min|, |max|) + if max < 0: a = 180 - a # 负 max 表示舵机反装,角度翻转 +""" + +MAX_ANGLE = 180.0 + + +class Servo: + + def __init__(self, max_angle, min_angle, offset, set_angle_callback): + self.max = max_angle + self.min = min_angle + self.offset = offset + self._set_angle = set_angle_callback + + def set_angle(self, angle): + a = angle + self.offset # 加上漂移值 + if a > abs(self.max): + a = abs(self.max) + if a < abs(self.min): + a = abs(self.min) + if self.max < 0.0: + a = MAX_ANGLE - a # 反装翻转 + self._set_angle(a) diff --git a/pybot/pybot/hd/speaker.py b/pybot/pybot/hd/speaker.py new file mode 100644 index 0000000..fb9879d --- /dev/null +++ b/pybot/pybot/hd/speaker.py @@ -0,0 +1,98 @@ +"""扬声器 —— 移植自 bot4cj/src/hd/snd.cj + +与仓颉版的差异(审计修复 S03): + 仓颉版 play 只是 sleep 占位、不出声;本版用标准库 wave 生成正弦波 + PCM 文件,经 `aplay -q` 播放(树莓派自带 alsa-utils)。 + aplay 不存在时降级为 sleep 占位(保持时序节拍不变); + dry_run 模式下仅记日志立即返回(不 sleep、不出声)。 + +时序约定:play() 阻塞总耗时对齐 delay_ms(与仓颉版 sleep(delay) 节拍一致), +播放提前结束则补足剩余时间。 +""" + +import logging +import math +import os +import shutil +import struct +import subprocess +import tempfile +import time +import wave + +log = logging.getLogger("pybot.speaker") + +FADE_MS = 5 # 首尾淡入淡出时长,消除爆音 + + +class Speaker: + """正弦波蜂鸣扬声器 + + volumn: 0.0~1.0 音量;max_f/min_f: 频率钳制范围(Hz) + """ + + def __init__(self, channels=1, sample_rate=44100, volumn=0.5, + max_f=20000.0, min_f=20.0, dry_run=False): + self.channels = channels + self.sample_rate = sample_rate + self.volumn = volumn + self.max_f = max_f + self.min_f = min_f + self.dry_run = dry_run + self._aplay = shutil.which("aplay") + if self._aplay is None and not dry_run: + log.warning("aplay not found, speaker degrades to silent sleep") + + def play(self, frequency, delay_ms): + """播放 frequency Hz 正弦波 delay_ms 毫秒(阻塞)""" + # 频率钳制(与仓颉版一致) + if frequency > self.max_f: + frequency = self.max_f + if frequency < self.min_f: + frequency = self.min_f + if self.dry_run: + log.info("speaker(dry-run): %.1fHz %dms", frequency, delay_ms) + return + start = time.monotonic() + if self._aplay is not None: + try: + self._play_aplay(frequency, delay_ms) + except (OSError, subprocess.SubprocessError) as e: + log.warning("aplay failed: %s", e) + # 补足剩余时间,保证总耗时对齐 delay_ms + remain = delay_ms / 1000.0 - (time.monotonic() - start) + if remain > 0: + time.sleep(remain) + + # ---------------- 内部 ---------------- + + def _synth(self, frequency, delay_ms): + """生成带 5ms 淡入淡出的 16bit PCM 正弦波""" + n = int(self.sample_rate * delay_ms / 1000.0) + fade = min(int(self.sample_rate * FADE_MS / 1000.0), n // 2) + amp = self.volumn * 32767.0 + w = 2.0 * math.pi * frequency / self.sample_rate + frames = bytearray() + for i in range(n): + gain = 1.0 + if i < fade: + gain = i / fade # 淡入 + elif i >= n - fade: + gain = (n - i) / fade # 淡出 + v = int(amp * gain * math.sin(w * i)) + frames += struct.pack(" fd=%d", path, fd) + return fd + + +def close_dev(fd): + _backend.close(fd) + log.debug("spi close fd=%d", fd) + + +def setup(fd, mode=0, bits=8, speed=500000): + _backend.setup(fd, mode, bits, speed) + + +def transfer(fd, tx, speed=500000, bits=8): + return _backend.transfer(fd, tx, speed, bits) diff --git a/pybot/pybot/main.py b/pybot/pybot/main.py new file mode 100644 index 0000000..5cd324a --- /dev/null +++ b/pybot/pybot/main.py @@ -0,0 +1,104 @@ +"""CLI 入口 —— 移植自 bot4cj/src/main.cj + +用法(与仓颉版管道方式兼容): + cat scripts/std.mt | python3 -m pybot # stdin 管道 + python3 -m pybot scripts/std.mt # 位置参数(增强) + python3 -m pybot --dry-run scripts/std.mt # Mock 总线,动作序列到 stdout + python3 -m pybot --test roundtrip in.mt out.mt # 解析↔序列化往返 + +与仓颉版的差异(见方案 §4.7): + --test nn/cnn/cnn1 移除(NN 由 actpred 承担); + --test matrix_o/matrix_2 合并为 --test roundtrip(路径改为参数); + 新增 --dry-run / --verbose;-i / -o 保留参数位(reserved,对接 actpred)。 +日志规范(S02 根治):全部诊断走 logging(stderr),stdout 保持纯净。 +""" + +import argparse +import logging +import sys + +from .hd import i2c, spi +from .parser import MatrixParser, MatrixSaver +from .robot import RobotStruct + + +def _build_argparser(): + p = argparse.ArgumentParser( + prog="pybot", + description="机器人 .mt 动作脚本解析器(bot4cj 的 Python 迁移版)") + p.add_argument("script", nargs="?", default=None, + help=".mt 脚本路径(缺省从 stdin 读取)") + p.add_argument("--test", nargs=3, metavar=("MODE", "IN", "OUT"), + help="自检:roundtrip 解析↔序列化往返") + p.add_argument("--dry-run", action="store_true", + help="Mock 总线运行(不碰硬件),动作序列输出到 stdout") + p.add_argument("--verbose", action="store_true", + help="DEBUG 级日志(stderr)") + p.add_argument("-i", metavar="FIFO", default=None, + help="(reserved) 传感器数据输出管道,对接 actpred") + p.add_argument("-o", metavar="FIFO", default=None, + help="(reserved) 动作指令输入管道,对接 actpred") + return p + + +def _roundtrip(in_path, out_path): + """解析 in.mt 再序列化到 out.mt(对应仓颉版 --test matrix_o/matrix_2)""" + objects = [] + with open(in_path, encoding="utf-8") as f: + parser = MatrixParser(f) + while True: + obj = parser.parse() + if obj is None: + break + objects.append(obj) + with open(out_path, "w", encoding="utf-8") as f: + MatrixSaver(f).save(objects) + logging.info("roundtrip: %d blocks, %s -> %s", + len(objects), in_path, out_path) + return 0 + + +def _dump_journal(*mocks): + """--dry-run 结束后把 Mock 总线动作序列输出到 stdout(等价性验证工件)""" + for mock in mocks: + for entry in mock.journal: + print(" ".join(str(x) for x in entry)) + + +def main(argv=None): + args = _build_argparser().parse_args(argv) + logging.basicConfig( + stream=sys.stderr, + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s %(name)s: %(message)s") + + if args.i or args.o: + logging.warning("-i/-o are reserved for actpred integration, ignored") + + if args.test: + mode, in_path, out_path = args.test + if mode != "roundtrip": + logging.error("unknown --test mode: %s (supported: roundtrip)", mode) + return 2 + return _roundtrip(in_path, out_path) + + mocks = () + if args.dry_run: + mocks = (i2c.use_mock(), spi.use_mock()) + + source = open(args.script, encoding="utf-8") if args.script else sys.stdin + try: + robot = RobotStruct(source, dry_run=args.dry_run) + with robot: + robot.run() + finally: + if args.script: + source.close() + + if args.dry_run: + _dump_journal(*mocks) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pybot/pybot/parser.py b/pybot/pybot/parser.py new file mode 100644 index 0000000..39bc066 --- /dev/null +++ b/pybot/pybot/parser.py @@ -0,0 +1,243 @@ +"""矩阵脚本(.mt)解析器 —— 移植自 bot4cj/src/P.cj + +与仓颉版的差异(均为审计修复,语义不变): + S01: submit_line 的列数校验修复为真实生效(仓颉版因 `!` 优先级恒假); + S02: 移除逐字符 debug 打印,stdout 保持纯净,诊断走 logging(stderr); + S04: 换行统一用 '\\n' 字符比较(Python 无 Rune/String 混淆问题); + Q05: 越界访问抛 MatrixIndexError(携带块名/行/列),报错可读。 +""" + +import logging + +log = logging.getLogger("pybot.parser") + + +# ---------------- 解析异常 ---------------- + +class ParseError(Exception): + """解析异常基类(对应仓颉版 ParseExcept)""" + + def __init__(self, msg, line): + super().__init__(f"{msg}. we got line: {line}") + self.line = line + + +class DoesNotSync(ParseError): + """行没对齐:某一行的列数与上一行不一致(修复 S01,携带期望/实际列数)""" + + def __init__(self, line, expected, actual): + super().__init__( + f"Matrix line does not sync (expected {expected} columns, got {actual})", + line) + self.expected = expected + self.actual = actual + + +class MatrixIndexError(Exception): + """矩阵越界访问(修复 Q05,携带块名/行/列)""" + + def __init__(self, name, row, col): + super().__init__(f"matrix @{name}: cell ({row}, {col}) out of range") + self.name = name + self.row = row + self.col = col + + +# ---------------- 矩阵编程语言 - 基本对象 ---------------- + +class MatrixObject: + """一个 @块(名字 + 原始字符串矩阵)""" + + def __init__(self): + self.name = "" # 块名(@ 后面的标识符) + self.values = [] # 原始矩阵 list[list[str]](含标题行) + self._tmp = [] # rune buf 字符缓冲区 + self._grp = [] # one line 整行缓冲区 + + # -------- 是否已命名 -------- + def has_name(self): + return self.name != "" + + # -------- 行数(含标题行)/ 列数 -------- + @property + def size(self): + return len(self.values) + + @property + def col(self): + return len(self.values[0]) if self.values else 0 + + # -------- 获取矩阵元素,只能取,不能修改 -------- + def __getitem__(self, rc): + row, col = rc + try: + return self.values[row][col] + except IndexError: + raise MatrixIndexError(self.name, row, col) from None + + # -------- 在某一行中寻找某一个字符串的列号 -------- + def index_of_col(self, row, key): + for c, item in enumerate(self.values[row]): + if item == key: + return c + raise KeyError(f"column '{key}' not found in matrix @{self.name}") + + # -------- 储存对象(与仓颉版 MatrixObject.save 输出格式一致) -------- + def save(self, out): + out.write(f"@{self.name}\n") + for vec in self.values: + for item in vec: + out.write(item) + out.write(" ") + out.write(";\n") + out.write("@\n") + + # -------- FSM 提交接口(由 MatrixParser 驱动) -------- + def add_rune(self, c): + self._tmp.append(c) + + def submit_name(self): + if not self._tmp: + return + self.name = "".join(self._tmp) + self._tmp.clear() + + def submit_item(self): + if not self._tmp: + return + self._grp.append("".join(self._tmp)) + self._tmp = [] + + def submit_line(self, count): + if not self._grp: + return + # 修复 S01:仓颉版 `!values.size == 0 && !grp.size == ...` 因优先级恒假 + if self.values and len(self._grp) != len(self.values[-1]): + raise DoesNotSync(count, len(self.values[-1]), len(self._grp)) + self.values.append(self._grp) + self._grp = [] + + +# ---------------- 矩阵式解析器(FSM 逐字符,状态与仓颉版一致) ---------------- + +class MatrixParser: + """输入文本流,逐块产出 MatrixObject(一次 parse() 解析一个 @块)""" + + def __init__(self, source): + self.reset(source) + + def reset(self, source): + """切换目标输入;source 为 str 或带 read() 的文本流""" + self._text = source if isinstance(source, str) else source.read() + self._pos = 0 + self._flag = False # 结束标志 + self._nl = True # new line + self._cm = False # comment + self._nm = False # name-line + self.obj = MatrixObject() + + def parse(self): + """解析下一个 @块;输入耗尽且无新块时返回 None""" + count = 1 # 行计数器始于 1 + self._flag = False # 重置结束标志 + self.obj = MatrixObject() # 一次只解析一个对象 + while not self._flag and self._pos < len(self._text): + c = self._text[self._pos] + self._pos += 1 + count = self._next(c, count) + + if not self.obj.has_name(): # 意外终止(无新对象) + return None + log.debug("parsed block @%s: %d rows x %d cols", + self.obj.name, self.obj.size, self.obj.col) + return self.obj + + def _next(self, c, ct): + """FSM 状态转移,与仓颉版 P.cj _next() 逐分支对齐(S02:无逐字符打印)""" + count = ct + # ------------ COMMENTS ------------ + if not self._cm and c == '#': # 注释开始时 + self._cm = True + elif self._cm and c == '\n': # 注释结束时 + self._nl = True + self._cm = False + count += 1 + elif self._cm: # 注释进行时 + pass + # ------------ new line ------------ + elif self._nl and c in ' \t\r': # 新行空字符,维持现状 + pass + elif self._nl and c == '\n': # 新行空行 + count += 1 + elif self._nl and c == '@' and not self.obj.has_name(): + self._nl = False + self._nm = True + elif self._nl and c == '@' and self.obj.has_name(): + self._flag = True # 块结束 + self._nl = False + self._nm = True + elif self._nl: # 新行内容 + self._nl = False + self.obj.add_rune(c) + # ------------- not nl ------------- + elif self._nm and c in ' \t\n': # 添加名字 + self.obj.submit_name() + self._nl = True + self._nm = False + elif not self._nm and c in ' \t\n': # 添加命名项(S04:'\n' 统一处理) + self.obj.submit_item() + if c == '\n': + count += 1 + elif c == ';': # 普通行结束 + self.obj.submit_item() + self.obj.submit_line(count) + self._nl = True + else: # 非空格、非制表符、非分号 + self.obj.add_rune(c) + return count + + +# ---------------- 矩阵式储存器 ---------------- + +class MatrixSaver: + """接收 MatrixObject 容器与输出流,序列化回 .mt 格式""" + + def __init__(self, out): + self._out = out + + def save(self, objects): + for item in objects: + item.save(self._out) + self._out.write("\n") # 对象之间空一行 + + +# ---------------- 占位符语义(与仓颉版 MatrixVars_Hardware.getMt/parseMt 对齐) ---------------- +# +# ⚠️ 与 actpred/mt_parser.py 的语义差异(不得误统一): +# actpred 数据管线首行 '-' 用 @Servo 初始角度填充; +# pybot 运行时首行 '-' 数值列解析为 0(Servo 有 max/min 钳位保护)。 + +def get_mt(obj, row, col): + """获得项目值:原始值是占位符且非首行(row>1),递归取上一行同列值""" + raw = obj[row, col] + if raw == "-" and row > 1: + return get_mt(obj, row - 1, col) + return raw + + +def parse_mt(obj, row, col, typ=float): + """解析项目值:占位符递归向上;首行(row==1)占位符解析为 typ("0")""" + raw = obj[row, col] + if raw == "-" and row > 1: + return parse_mt(obj, row - 1, col, typ) + if raw == "-": + return typ("0") + return typ(raw) + + +def to_int(s): + """整数解析,支持 0x 十六进制前缀(@PCA9685 address、@MPU6050_BIND addr 列)""" + s = s.strip() + if s.lower().startswith("0x") or s.lower().startswith("-0x"): + return int(s, 16) + return int(s) diff --git a/pybot/pybot/plugin.py b/pybot/pybot/plugin.py new file mode 100644 index 0000000..9f5b00e --- /dev/null +++ b/pybot/pybot/plugin.py @@ -0,0 +1,102 @@ +"""Python 插件加载器 —— 替代 bot4cj/src/r.cj 的 Lua 状态机桥接(MtVars_Lua) + +插件协议(modules/.py,替代 Lua build/call/destroy,见方案 §3.4): + def build(ctx: dict) -> dict # 初始化硬件 + def call(ctx: dict) -> dict # ctx = 元数据 + 实时参数 + def destroy(ctx: dict) -> dict # 复位/关闭硬件 + +与仓颉版的差异(审计修复 S07): + Lua 路径硬编码 ./modules/ → 三级查找: + $PYBOT_MODULES_PATH(冒号分隔多路径)→ 包内置 modules/ → cwd/modules/。 + +每次加载都创建独立模块实例(spec_from_file_location 唯一模块名), +对齐仓颉版"每个设备一个独立 Lua 状态机"的隔离语义 +(插件内模块级全局变量即设备私有状态)。 +""" + +import importlib.util +import itertools +import logging +import os +from pathlib import Path + +from .hardvars import HardwareVar + +log = logging.getLogger("pybot.plugin") + +_instance_counter = itertools.count() + + +class PluginNotFound(Exception): + """插件未找到(携带插件名与全部搜索路径)""" + + def __init__(self, name, searched): + paths = ", ".join(str(p) for p in searched) + super().__init__(f"plugin '{name}' not found (searched: {paths})") + self.name = name + self.searched = searched + + +def search_dirs(): + """插件搜索目录(按优先级):$PYBOT_MODULES_PATH → 包内置 → cwd""" + dirs = [] + env = os.environ.get("PYBOT_MODULES_PATH", "") + for p in env.split(":"): + if p: + dirs.append(Path(p)) + dirs.append(Path(__file__).resolve().parent.parent / "modules") # 包内置 + dirs.append(Path.cwd() / "modules") + return dirs + + +def find_module(name): + """按三级路径查找 .py,找不到抛 PluginNotFound""" + searched = search_dirs() + for d in searched: + path = d / f"{name}.py" + if path.is_file(): + return path + raise PluginNotFound(name, searched) + + +def load_module(name): + """加载插件为独立模块实例(每次调用产生新实例,状态互不共享)""" + path = find_module(name) + mod_name = f"pybot_plugin_{name}_{next(_instance_counter)}" + spec = importlib.util.spec_from_file_location(mod_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for fn in ("build", "call", "destroy"): + if not callable(getattr(module, fn, None)): + raise PluginNotFound( + name, [path]) from ValueError( + f"plugin '{name}' missing function {fn}(ctx)") + log.debug("loaded plugin %s from %s", name, path) + return module + + +class PluginVar(HardwareVar): + """插件硬件变量(对应仓颉版 MtVars_Lua) + + metadata: @块行元数据 + 引用硬件的 play({}) 展开结果(如 I2C → fd) + """ + + def __init__(self, name, block_name, metadata): + super().__init__(name) + self.metadata = metadata + self.module = load_module(block_name) + + def build(self): + ctx = dict(self.metadata) + log.info("plugin build parameters: %s", ctx) + return self.module.build(ctx) + + def play(self, params): + ctx = dict(self.metadata) # 元数据拷贝,合并实时参数 + ctx.update(params) + return self.module.call(ctx) + + def destroy(self): + ctx = dict(self.metadata) + log.info("plugin destroy parameters: %s", ctx) + return self.module.destroy(ctx) diff --git a/pybot/pybot/robot.py b/pybot/pybot/robot.py new file mode 100644 index 0000000..fb0c588 --- /dev/null +++ b/pybot/pybot/robot.py @@ -0,0 +1,177 @@ +"""机器人结构 —— 移植自 bot4cj/src/r.cj 的 RobotStruct + +流程:解析 .mt 脚本 → 创建硬件变量字典 → active(按序 build) + → run(逐行执行 @Action)→ deactive(倒序 destroy)。 + +与仓颉版的差异(审计修复,语义不变): + r.cj:911 FIXME —— 重名硬件变量先 destroy 旧值再覆盖,并输出警告; + Q05: @Action 列名合法性(必须含一个 '.')在执行前整表校验一次; + Q07: deactive 幂等 + atexit 兜底 + with 上下文管理器,确定性释放 fd。 +""" + +import atexit +import logging + +from . import plugin +from .hardvars import (AliasVar, I2CVar, MPU6050Var, ServoVar, SpeakerVar) +from .parser import MatrixParser, MatrixSaver, get_mt, parse_mt + +log = logging.getLogger("pybot.robot") + +# 已知块名 → 硬件变量工厂(Action 特殊处理;未知块 → 插件) +_FACTORIES = { + "I2C": I2CVar, + "Alias": AliasVar, + "Servo": ServoVar, + "Speaker": SpeakerVar, + "MPU6050_BIND": MPU6050Var, +} + + +class RobotStruct: + """机器人结构:从矩阵脚本动态构建 + + 用法: + with RobotStruct(sys.stdin) as robot: # __enter__ → active() + robot.run() # __exit__ → deactive() + """ + + def __init__(self, source, dry_run=False): + self.C = [] # 解析出的 MatrixObject 容器(保持脚本顺序) + self.M = {} # {name: HardwareVar}(dict 有序,支撑倒序 destroy) + self.dry_run = dry_run + self._deactivated = False + self._read_matrix(source) + self._create_hardvars() + atexit.register(self.deactive) # Q07 兜底:异常退出也释放 fd + + # ---------------- 生命周期 ---------------- + + def active(self): + """激活所有硬件变量(按创建顺序 build)""" + for item in self.M.values(): + item.build() + + def deactive(self): + """倒序释放所有硬件变量;幂等(with / atexit 双路径安全)""" + if self._deactivated: + return + self._deactivated = True + for item in reversed(list(self.M.values())): + try: + item.destroy() + except Exception: + log.exception("destroy %s failed", item.name) + + def __enter__(self): + self.active() + return self + + def __exit__(self, exc_type, exc, tb): + self.deactive() + return False + + # ---------------- 解析与构建 ---------------- + + def _read_matrix(self, source): + parser = MatrixParser(source) + while True: + obj = parser.parse() + if obj is None: + break + log.debug("read matrix block @%s", obj.name) + self.C.append(obj) + + def _put_hardvar(self, name, var): + """插入硬件变量;重名先释放旧值(修复 r.cj:911 FIXME)""" + old = self.M.get(name) + if old is not None: + log.warning("hard-Var '%s' redefined, destroying the old one", name) + old.destroy() + self.M[name] = var + + def _create_hardvars(self): + """按块名分发工厂;Action 跳过;未知块 → Python 插件""" + for matrix in self.C: + mt_name = matrix.name + if mt_name == "Action": + continue + factory = _FACTORIES.get(mt_name) + if factory is not None: + idx_name = matrix.index_of_col(0, "name") + for i in range(1, matrix.size): + name = matrix[i, idx_name] + self._put_hardvar(name, factory.from_matrix( + matrix, i, self.M, dry_run=self.dry_run)) + elif matrix.size > 0: + self._create_plugin_vars(matrix) + + def _create_plugin_vars(self, matrix): + """未知块:逐行构建元数据并加载 modules/<块名>.py 插件""" + idx_name = matrix.index_of_col(0, "name") + for i in range(1, matrix.size): + name = matrix[i, idx_name] + metadata = {} + for j in range(matrix.col): + col_name = matrix[0, j] + value = get_mt(matrix, i, j) + ref = self.M.get(value) + if ref is not None: + # 值是已存在的硬件变量名 → play({}) 展开(如 I2C → fd) + metadata.update(ref.play({})) + else: + metadata[col_name] = value + self._put_hardvar(name, plugin.PluginVar(name, matrix.name, + metadata)) + + # ---------------- 执行 ---------------- + + def find_mt(self, name): + """在矩阵对象容器中寻找目标块""" + for item in self.C: + if item.name.strip() == name: + return item + return None + + def run(self): + """逐行执行 @Action 矩阵""" + action = self.find_mt("Action") + if action is None: + raise ValueError("no @Action matrix in script") + columns = self._validate_action(action) # Q05: 执行前整表校验 + for i in range(1, action.size): + row_sets = {} # {设备名: {参数名: float}} + for j, (dev, param) in enumerate(columns): + # 可能的参数别名转换(A → angle) + alias = self.M.get(param) + if isinstance(alias, AliasVar): + param = alias.play({}) + val = parse_mt(action, i, j) + row_sets.setdefault(dev, {})[param] = val + # 执行一行:逐设备调用 play + for dev, params in row_sets.items(): + self.M[dev].play(params) + + def _validate_action(self, action): + """校验 @Action 全部列名为 '设备.参数' 且设备已定义,返回 [(dev, param)]""" + columns = [] + for j in range(action.col): + col_name = action[0, j] + parts = col_name.split(".") + if len(parts) != 2: + raise ValueError( + f"@Action column {j} '{col_name}' is not valid " + f"(expected 'device.param')") + dev, param = parts + if dev not in self.M: + raise ValueError( + f"@Action column {j} '{col_name}': " + f"device '{dev}' is not defined") + columns.append((dev, param)) + return columns + + # ---------------- 序列化 ---------------- + + def save(self, out): + """储存整个矩阵变量表(--test roundtrip 用)""" + MatrixSaver(out).save(self.C) diff --git a/pybot/requirements.txt b/pybot/requirements.txt new file mode 100644 index 0000000..ac94880 --- /dev/null +++ b/pybot/requirements.txt @@ -0,0 +1,3 @@ +# 运行时零第三方依赖(纯标准库:fcntl/struct/threading/wave/argparse) +# 以下仅开发测试用 +pytest>=7.0 diff --git a/pybot/scripts b/pybot/scripts new file mode 120000 index 0000000..e4bef60 --- /dev/null +++ b/pybot/scripts @@ -0,0 +1 @@ +../bot4cj/scripts \ No newline at end of file diff --git a/pybot/tests/__init__.py b/pybot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybot/tests/conftest.py b/pybot/tests/conftest.py new file mode 100644 index 0000000..c73806f --- /dev/null +++ b/pybot/tests/conftest.py @@ -0,0 +1,30 @@ +"""pytest 公共配置:把项目根加入 sys.path(无需安装即可 import pybot)""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import pytest # noqa: E402 + +from pybot.hd import i2c, spi # noqa: E402 + + +SCRIPTS_DIR = ROOT / "scripts" + + +@pytest.fixture() +def mock_i2c(): + """切换到 Mock I2C 总线,测试结束恢复真实后端""" + mock = i2c.use_mock() + yield mock + i2c.use_real() + + +@pytest.fixture() +def mock_spi(): + """切换到 Mock SPI 总线,测试结束恢复真实后端""" + mock = spi.use_mock() + yield mock + spi.use_real() diff --git a/pybot/tests/test_hardvars.py b/pybot/tests/test_hardvars.py new file mode 100644 index 0000000..480503c --- /dev/null +++ b/pybot/tests/test_hardvars.py @@ -0,0 +1,121 @@ +"""硬件变量 + RobotStruct 全流程测试(Mock I2C 总线,journal 断言) + +关键期望值(与 bot4cj + PCA9685.lua 逐字节对齐): + 45° → 脉宽 0.5+2*45/180 = 1.0ms → offval = int(1.0*4096/20) = 204 + 135° → 脉宽 2.0ms → offval = int(2.0*4096/20) = 409(s3 反装 45°→135°) + 0° → 脉宽 0.5ms → offval = 102(首行 '-' 占位解析为 0) +""" + +from pybot.robot import RobotStruct +from tests.conftest import SCRIPTS_DIR + +PRESCALE = 121 # int(25e6/(4096*50) - 1 + 0.5) + + +def std_robot(mock_i2c): + with open(SCRIPTS_DIR / "std.mt", encoding="utf-8") as f: + return RobotStruct(f, dry_run=True) + + +def _writes(mock, fd): + return [(e[2], e[3]) for e in mock.journal + if e[0] == "write_reg" and e[1] == fd] + + +def test_create_hardvars(mock_i2c): + robot = std_robot(mock_i2c) + assert set(robot.M) == {"A", "F", "i2c-0", "i2c-1", "pca9685-0", "spk", + "s1", "s2", "s3", "s4", "s5", + "s6", "s7", "s8", "s9", "s10"} + # 两条 I2C 总线在工厂阶段即打开 + opens = [e for e in mock_i2c.journal if e[0] == "open"] + assert [e[1] for e in opens] == ["/dev/i2c-0", "/dev/i2c-1"] + # 别名变量 + assert robot.M["A"].play({}) == "angle" + assert robot.M["F"].play({}) == "frequency" + robot.deactive() + + +def test_pca9685_build_sequence(mock_i2c): + robot = std_robot(mock_i2c) + fd = robot.M["i2c-1"].play({})["fd"] + robot.active() + # 初始化序列:复位 → SLEEP → prescale=121 → 唤醒(与 Lua 版一致) + assert _writes(mock_i2c, fd) == [ + (0x00, 0x80), (0x00, 0x10), (0xFE, PRESCALE), (0x00, 0x20)] + assert ("set_addr", fd, 0x40) in mock_i2c.journal + robot.deactive() + + +def test_run_stand_action(mock_i2c): + robot = std_robot(mock_i2c) + fd = robot.M["i2c-1"].play({})["fd"] + robot.active() + mock_i2c.journal.clear() # 只看 @Action 阶段 + robot.run() + writes = _writes(mock_i2c, fd) + reg = lambda ch: 0x06 + 4 * ch # noqa: E731 + + def servo_writes(ch, offval): + return [(reg(ch) + 0, 0x00), (reg(ch) + 1, 0x00), + (reg(ch) + 2, offval & 0xFF), (reg(ch) + 3, offval >> 8)] + + # s1: 45° 正装 → offval 204;s2: '-' 首行 → 0° → 102 + assert writes[0:4] == servo_writes(0, 204) + assert writes[4:8] == servo_writes(1, 102) + # s3: max=-90 反装,45° → 135° → offval 409(写 [153, 1]) + assert writes[8:12] == servo_writes(2, 409) + # s9/s10: 90° → offval = int(1.5*4096/20) = 307 + assert writes[32:36] == servo_writes(8, 307) + assert writes[36:40] == servo_writes(9, 307) + # 10 舵机 × 4 寄存器 + assert len(writes) == 40 + robot.deactive() + + +def test_deactive_reverse_and_idempotent(mock_i2c): + robot = std_robot(mock_i2c) + fd0 = robot.M["i2c-0"].play({})["fd"] + fd1 = robot.M["i2c-1"].play({})["fd"] + robot.active() + robot.deactive() + # 倒序:先 PCA9685 复位(fd1 写 0x80),再关 i2c-1,最后关 i2c-0 + closes = [e[1] for e in mock_i2c.journal if e[0] == "close"] + assert closes == [fd1, fd0] + reset_idx = mock_i2c.journal.index(("write_reg", fd1, 0x00, 0x80), + mock_i2c.journal.index(("close", fd1)) + - 5) + assert reset_idx < mock_i2c.journal.index(("close", fd1)) + # 幂等:重复调用不再产生操作 + n = len(mock_i2c.journal) + robot.deactive() + assert len(mock_i2c.journal) == n + + +def test_redefined_hardvar_destroys_old(mock_i2c, caplog): + """重名硬件变量:destroy 旧值 + 告警(修复 r.cj:911 FIXME)""" + text = ("@I2C\nname path;\nx /dev/i2c-0;\nx /dev/i2c-1;\n@\n" + "@Action\nx.angle;\n0;\n@\n") + robot = RobotStruct(text, dry_run=True) + closes = [e for e in mock_i2c.journal if e[0] == "close"] + assert len(closes) == 1 # 旧的 /dev/i2c-0 已被关闭 + assert any("redefined" in r.message for r in caplog.records) + robot.deactive() + + +def test_action_validation(mock_i2c): + """Q05:执行前整表校验列名格式与设备存在性""" + import pytest + text = ("@I2C\nname path;\ni /dev/i2c-0;\n@\n" + "@Action\nbadcolumn;\n0;\n@\n") + robot = RobotStruct(text, dry_run=True) + with pytest.raises(ValueError, match="not valid"): + robot.run() + robot.deactive() + + text2 = ("@I2C\nname path;\ni /dev/i2c-0;\n@\n" + "@Action\nghost.angle;\n0;\n@\n") + robot2 = RobotStruct(text2, dry_run=True) + with pytest.raises(ValueError, match="not defined"): + robot2.run() + robot2.deactive() diff --git a/pybot/tests/test_mpu6050.py b/pybot/tests/test_mpu6050.py new file mode 100644 index 0000000..773eb2c --- /dev/null +++ b/pybot/tests/test_mpu6050.py @@ -0,0 +1,48 @@ +"""MPU6050 测试:14 字节连读解包(含负值符号,回归 S06)与采样滤波""" + +import struct + +from pybot.hd import i2c +from pybot.hd.mpu6050 import (ACCEL_CONFIG, ACCEL_XOUT_H, ADDR_0, ADDR_1, + GYRO_CONFIG, MPU6050, PWR_MGMT_1) + + +def test_addr_constants(): + """修复 S05:ADDR_1 必须是 0x69(仓颉版误写 0x68)""" + assert ADDR_0 == 0x68 + assert ADDR_1 == 0x69 + + +def test_setup_sequence(mock_i2c): + fd = i2c.open_dev("/dev/i2c-1") + m = MPU6050(fd, ADDR_0) + m.setup() + writes = [(e[2], e[3]) for e in mock_i2c.journal if e[0] == "write_reg"] + assert writes == [(PWR_MGMT_1, 0x00), # 解除休眠 + (GYRO_CONFIG, 0x08), # ±500°/s + (ACCEL_CONFIG, 0x10)] # ±8g + assert ("set_addr", fd, ADDR_0) in mock_i2c.journal + + +def test_read_sensor_signed(mock_i2c): + """负值(高字节 ≥ 0x80)必须正确解为负数——回归 S06 符号扩展错误""" + fd = i2c.open_dev("/dev/i2c-1") + m = MPU6050(fd) + # ax=-2 ay=3 az=4096 temp=999(跳过) gx=100 gy=-200 gz=300 + mock_i2c.block_values[(fd, ACCEL_XOUT_H)] = struct.pack( + ">7h", -2, 3, 4096, 999, 100, -200, 300) + assert m.read_sensor() == (-2, 3, 4096, 100, -200, 300) + + +def test_sample_and_latest(mock_i2c): + fd = i2c.open_dev("/dev/i2c-1") + m = MPU6050(fd) + # 静止平放:az=4096(+1g@±8g),其余 0 → roll/pitch 趋近 0 + mock_i2c.block_values[(fd, ACCEL_XOUT_H)] = struct.pack( + ">7h", 0, 0, 4096, 0, 0, 0, 0) + for _ in range(50): # 多次采样让卡尔曼收敛 + m._sample_once() + latest = m.latest() + assert abs(latest["roll"]) < 1.0 + assert abs(latest["pitch"]) < 1.0 + assert latest["az"] > 3000.0 # 滤波值向测量值收敛 diff --git a/pybot/tests/test_parser.py b/pybot/tests/test_parser.py new file mode 100644 index 0000000..d7b6eeb --- /dev/null +++ b/pybot/tests/test_parser.py @@ -0,0 +1,112 @@ +"""解析器测试:FSM / 列数校验(S01) / 占位符语义 / 序列化往返 / 全量脚本""" + +import io + +import pytest + +from pybot.parser import (DoesNotSync, MatrixParser, MatrixSaver, + get_mt, parse_mt) +from tests.conftest import SCRIPTS_DIR + + +def parse_all(text): + parser = MatrixParser(text) + objects = [] + while True: + obj = parser.parse() + if obj is None: + break + objects.append(obj) + return objects + + +# ---------------- 基本解析 ---------------- + +def test_parse_simple_block(): + objs = parse_all("@Foo\nname val;\na 1;\nb 2;\n@\n") + assert len(objs) == 1 + m = objs[0] + assert m.name == "Foo" + assert m.size == 3 and m.col == 2 + assert m[0, 0] == "name" and m[2, 1] == "2" + + +def test_parse_multiple_blocks_and_comments(): + text = ("# 头部注释\n" + "@A\nx y;\n1 2;\n@\n" + "@B\nn;\nv; # 行内注释\n@\n") + objs = parse_all(text) + assert [o.name for o in objs] == ["A", "B"] + assert objs[1][1, 0] == "v" + + +def test_parse_std_mt(): + objs = parse_all((SCRIPTS_DIR / "std.mt").read_text(encoding="utf-8")) + names = [o.name for o in objs] + assert names == ["Alias", "I2C", "PCA9685", "MPU6050_BIND", + "Speaker", "Servo", "Action"] + servo = objs[names.index("Servo")] + assert servo.size == 11 and servo.col == 6 # 表头 + 10 舵机 + # 注释掉的 MPU6050_BIND 数据行不产生内容 + assert objs[names.index("MPU6050_BIND")].size == 1 + + +@pytest.mark.parametrize("script", sorted(SCRIPTS_DIR.glob("*.mt")), + ids=lambda p: p.name) +def test_parse_all_scripts(script): + """全量 scripts/*.mt 解析:块内每行列数一致且非空""" + objs = parse_all(script.read_text(encoding="utf-8")) + assert objs, f"{script.name}: no blocks parsed" + for o in objs: + assert o.name + for row in o.values: + assert len(row) == o.col + + +# ---------------- S01:列数校验 ---------------- + +def test_does_not_sync(): + with pytest.raises(DoesNotSync) as ei: + parse_all("@Foo\na b c;\n1 2;\n@\n") + assert ei.value.expected == 3 and ei.value.actual == 2 + + +def test_missing_semicolon_merges_lines(): + """漏写分号导致跨行合并成一行(S04 回归场景),列数不齐即报错""" + with pytest.raises(DoesNotSync): + parse_all("@Foo\na b;\n1 2\n3 4;\n@\n") + + +# ---------------- 占位符语义 ---------------- + +def _matrix(text): + return parse_all(text)[0] + + +def test_placeholder_recursion(): + m = _matrix("@T\nname v;\na 5;\nb -;\nc -;\n@\n") + assert get_mt(m, 3, 1) == "5" # 递归两级到首行 + assert parse_mt(m, 3, 1) == 5.0 + + +def test_placeholder_first_row(): + """首行 '-':get_mt 返回字面 '-';parse_mt 解析为 typ("0") + ⚠️ 与 actpred/mt_parser.py 不同(后者用 @Servo 初始角度填充),不得误统一""" + m = _matrix("@T\nname v;\na -;\n@\n") + assert get_mt(m, 1, 1) == "-" + assert parse_mt(m, 1, 1) == 0.0 + assert parse_mt(m, 1, 1, int) == 0 + + +# ---------------- 序列化往返 ---------------- + +def test_roundtrip(): + text = (SCRIPTS_DIR / "std.mt").read_text(encoding="utf-8") + objs = parse_all(text) + out = io.StringIO() + MatrixSaver(out).save(objs) + objs2 = parse_all(out.getvalue()) + assert len(objs2) == len(objs) + for a, b in zip(objs, objs2): + assert a.name == b.name + assert a.values == b.values diff --git a/pybot/tests/test_plugin.py b/pybot/tests/test_plugin.py new file mode 100644 index 0000000..211967c --- /dev/null +++ b/pybot/tests/test_plugin.py @@ -0,0 +1,49 @@ +"""插件加载器测试:三级路径查找(S07) / 缺失报错 / 实例隔离""" + +import pytest + +from pybot import plugin + + +def test_find_builtin_module(): + """包内置 modules/ 能找到 PCA9685""" + path = plugin.find_module("PCA9685") + assert path.name == "PCA9685.py" + assert path.parent.name == "modules" + + +def test_plugin_not_found(): + with pytest.raises(plugin.PluginNotFound) as ei: + plugin.find_module("NoSuchDevice") + assert "NoSuchDevice" in str(ei.value) + assert "modules" in str(ei.value) # 报错含搜索路径 + + +def test_env_path_priority(tmp_path, monkeypatch): + """$PYBOT_MODULES_PATH 优先于包内置目录""" + (tmp_path / "PCA9685.py").write_text( + "def build(ctx): return {'from': 'env'}\n" + "def call(ctx): return {}\n" + "def destroy(ctx): return {}\n", encoding="utf-8") + monkeypatch.setenv("PYBOT_MODULES_PATH", str(tmp_path)) + assert plugin.find_module("PCA9685") == tmp_path / "PCA9685.py" + mod = plugin.load_module("PCA9685") + assert mod.build({}) == {"from": "env"} + + +def test_missing_protocol_function(tmp_path, monkeypatch): + """插件缺 build/call/destroy 任一函数即拒绝加载""" + (tmp_path / "Broken.py").write_text( + "def build(ctx): return {}\n", encoding="utf-8") + monkeypatch.setenv("PYBOT_MODULES_PATH", str(tmp_path)) + with pytest.raises(plugin.PluginNotFound): + plugin.load_module("Broken") + + +def test_independent_instances(): + """每次加载新模块实例:模块级全局状态互不共享(对齐 Lua 每设备状态机)""" + a = plugin.load_module("SPI") + b = plugin.load_module("SPI") + assert a is not b + a._fd = 42 + assert b._fd == -1 diff --git a/pybot/tests/test_servo.py b/pybot/tests/test_servo.py new file mode 100644 index 0000000..e56629e --- /dev/null +++ b/pybot/tests/test_servo.py @@ -0,0 +1,47 @@ +"""Servo 校准算法测试:正装/反装/漂移/钳位(与 Servo.cj 语义逐项对齐)""" + +from pybot.hd.servo import Servo + + +def make(max_a, min_a, offset): + sent = [] + servo = Servo(max_a, min_a, offset, sent.append) + return servo, sent + + +def test_normal_install(): + servo, sent = make(90.0, 0.0, 0.0) + servo.set_angle(45.0) + assert sent == [45.0] + + +def test_clamp_max(): + servo, sent = make(90.0, 0.0, 0.0) + servo.set_angle(120.0) + assert sent == [90.0] + + +def test_clamp_min(): + servo, sent = make(180.0, 30.0, 0.0) + servo.set_angle(10.0) + assert sent == [30.0] + + +def test_offset(): + servo, sent = make(180.0, 0.0, 5.0) + servo.set_angle(40.0) + assert sent == [45.0] + + +def test_inverted_install(): + """负 max 表示反装:a = 180 - a(std.mt 的 s3 max=-90,45° → 135°)""" + servo, sent = make(-90.0, 0.0, 0.0) + servo.set_angle(45.0) + assert sent == [180.0 - 45.0] + + +def test_inverted_clamp_uses_abs(): + """反装钳位用 |max|:-90 装机 100° → 钳到 90 → 翻转 90""" + servo, sent = make(-90.0, 0.0, 0.0) + servo.set_angle(100.0) + assert sent == [90.0] From 08df3929b2481db24a1bb4c7a74828ac35b92cdf Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:16:28 +0800 Subject: [PATCH 017/196] =?UTF-8?q?feat(core):=20services.yaml=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=85=8D=E7=BD=AE=20+=20=5Frun.sh=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=82=E6=95=B0=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1: init.txt → services.yaml - 新增 InitParser (yaml-cpp), 支持 YAML/TXT 双模式解析, 向后兼容 init.txt - 新增 services.yaml 声明式服务配置 (name/cmd/args/depends_on/sleep_before) - 改造 pmc_mtd.cpp 集成 InitParser, 支持目录/.yaml/.txt 三种路径模式 - Makefile 添加 InitParser 编译目标 + -lyaml-cpp 链接 - setup 脚本添加 libyaml-cpp-dev / yaml-cpp-devel 依赖 - core/_run.sh 改为 -i . (目录模式, 自动查找 services.yaml → init.txt) Step 2: 11 个 _run.sh 统一参数透传 - 8 个脚本改动: 追加 "$@" / 移除 sleep / 修引号 - waf 保留 -c config.yaml 作为脚本默认值 - services.yaml waf args 改为 [] 避免重复传递 - 开发方案文档: docs/reports/core_services_yaml_dev_doc.md --- _setup.deb.sh | 2 +- _setup.rhel.sh | 2 +- ai_review/_run.sh | 2 +- aibox/_run.sh | 2 +- ca/_run.sh | 4 +- captcha2/_run.sh | 3 +- core/Makefile | 11 +- core/_run.sh | 2 +- core/include/registry/InitParser.hh | 49 ++ core/services.yaml | 87 +++ core/src/main.cpp | 2 +- core/src/main/pmc_mtd.cpp | 72 +- core/src/registry/InitParser.cpp | 131 ++++ docs/reports/core_services_yaml_dev_doc.md | 806 +++++++++++++++++++++ gateway/_run.sh | 4 +- redirect80/_run.sh | 2 +- smtp/_run.sh | 3 +- waf/_run.sh | 2 +- 18 files changed, 1144 insertions(+), 42 deletions(-) create mode 100644 core/include/registry/InitParser.hh create mode 100644 core/services.yaml create mode 100644 core/src/registry/InitParser.cpp create mode 100644 docs/reports/core_services_yaml_dev_doc.md diff --git a/_setup.deb.sh b/_setup.deb.sh index 4f93f8a..e0a2343 100755 --- a/_setup.deb.sh +++ b/_setup.deb.sh @@ -2,4 +2,4 @@ # Debian sudo apt update -sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf golang-go libportaudio-ocaml-dev libfreetype-dev +sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf golang-go libportaudio-ocaml-dev libfreetype-dev libyaml-cpp-dev diff --git a/_setup.rhel.sh b/_setup.rhel.sh index 885a9d6..78a25bd 100755 --- a/_setup.rhel.sh +++ b/_setup.rhel.sh @@ -1,4 +1,4 @@ #!/bin/bash # RHEL / CentOS / Rocky / Fedora -sudo dnf install -y boost-devel libcurl-devel python3 python3-pip gcc-c++ patchelf golang sqlite-devel freetype-devel +sudo dnf install -y boost-devel libcurl-devel python3 python3-pip gcc-c++ patchelf golang sqlite-devel freetype-devel yaml-cpp-devel diff --git a/ai_review/_run.sh b/ai_review/_run.sh index 90fc521..1e58925 100644 --- a/ai_review/_run.sh +++ b/ai_review/_run.sh @@ -2,5 +2,5 @@ cd "$(dirname "$0")" || exit source ./venv/bin/activate #exec ./dnfs.bin -exec python3 ai_review.py +exec python3 ai_review.py "$@" diff --git a/aibox/_run.sh b/aibox/_run.sh index b465f80..d206b5f 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 aibox.py $@ +exec python aibox.py "$@" diff --git a/ca/_run.sh b/ca/_run.sh index 8c24dde..67da3b7 100755 --- a/ca/_run.sh +++ b/ca/_run.sh @@ -4,7 +4,5 @@ # 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 cd "$(dirname "$0")" || exit -# 等待依赖服务启动(confs/db) -sleep 2 - +# sleep 已移至 services.yaml 的 sleep_before 字段统一管理 exec ./build/ca.out "$@" diff --git a/captcha2/_run.sh b/captcha2/_run.sh index 280ce23..cfb6f83 100755 --- a/captcha2/_run.sh +++ b/captcha2/_run.sh @@ -1,4 +1,3 @@ #!/bin/bash cd "$(dirname "$0")" || exit -sleep 1 -exec ./captcha2.bin +exec ./captcha2.bin "$@" diff --git a/core/Makefile b/core/Makefile index 7e888e6..fd88b6f 100644 --- a/core/Makefile +++ b/core/Makefile @@ -4,7 +4,7 @@ APP_NAME := nidus INSTL_DIRS := /usr/local/bin -CONF_NAME := init.txt +CONF_NAME := services.yaml CONF_DIR := /etc/Nidus @@ -17,7 +17,7 @@ CPP:= g++ INCS:= -I include/ MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lus_msg -lcurl -LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem +LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem -lyaml-cpp CPPFLAGS := --std=c++20 -O3 -pipe #-Wall -Werror @@ -26,7 +26,7 @@ $(shell mkdir -p $(BUILD_DIR)) #-------- all: ${APP_NAME}.out -${APP_NAME}.out: $(BUILD_DIR)/main.o $(BUILD_DIR)/PPool.o $(BUILD_DIR)/ProcessTask.o $(BUILD_DIR)/HttpTask.o $(BUILD_DIR)/ConfsTask.o $(BUILD_DIR)/Tttask.o $(BUILD_DIR)/pmc_mtd.o $(BUILD_DIR)/subsys_call.o +${APP_NAME}.out: $(BUILD_DIR)/main.o $(BUILD_DIR)/PPool.o $(BUILD_DIR)/ProcessTask.o $(BUILD_DIR)/HttpTask.o $(BUILD_DIR)/ConfsTask.o $(BUILD_DIR)/Tttask.o $(BUILD_DIR)/pmc_mtd.o $(BUILD_DIR)/subsys_call.o $(BUILD_DIR)/InitParser.o $(CPP) $^ $(MY_LIBS) $(LIBS) -o $@ $(BUILD_DIR)/main.o: src/main.cpp @@ -53,6 +53,9 @@ $(BUILD_DIR)/pmc_mtd.o: src/main/pmc_mtd.cpp $(BUILD_DIR)/subsys_call.o: src/main/subsys_call.cpp $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ +$(BUILD_DIR)/InitParser.o: src/registry/InitParser.cpp + $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ + install: $(APP_NAME).out @@ -65,6 +68,7 @@ install: $(APP_NAME).out @echo "正在配置 $(APP_NAME)" @mkdir -p $(CONF_DIR) @cp $(CONF_NAME) $(CONF_DIR)/$(CONF_NAME) + @cp init.txt $(CONF_DIR)/init.txt @cp $(SRV_NAME) $(SRV_DIR)/$(SRV_NAME) @systemctl daemon-reload @systemctl enable $(SRV_NAME) @@ -77,6 +81,7 @@ uninstall: @ @rm -f $(INSTL_DIRS)/$(APP_NAME) @rm -f $(CONF_DIRS)/$(CONF_NAME) + @rm -f $(CONF_DIRS)/init.txt @rm -f -d ${CONF_DIRS} @ @systemctl stop $(SRV_NAME) diff --git a/core/_run.sh b/core/_run.sh index dde1669..87a2704 100644 --- a/core/_run.sh +++ b/core/_run.sh @@ -1,4 +1,4 @@ #!/bin/bash cd "$(dirname "$0")" || exit export LD_LIBRARY_PATH=/lib:$LD_LIBRARY_PATH -./nidus.out -i init.txt --serve 5200 --confs 8010 +./nidus.out -i . --serve 5200 --confs 8010 diff --git a/core/include/registry/InitParser.hh b/core/include/registry/InitParser.hh new file mode 100644 index 0000000..f40939e --- /dev/null +++ b/core/include/registry/InitParser.hh @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace qing::registry { + +/* 服务启动配置项 + * + * 从 services.yaml 或 init.txt 解析出的单个服务声明。 + * YAML 模式下包含完整的元数据;TXT 模式下仅 cmd 有值。 + */ +struct ServiceConfig { + std::string name; /* 服务唯一标识 */ + std::string cmd; /* 启动命令(如 bash /opt/.../_run.sh) */ + std::vector args; /* 传递给 _run.sh 的命令行参数 */ + std::map env; /* 启动前设置的环境变量 */ + std::vector depends_on; /* 依赖的服务名列表(未来 DAG 排序用) */ + int sleep_before = 0; /* 启动前等待秒数 */ + + /* 拼接完整命令:cmd + " " + args... */ + std::string toCommand() const; +}; + +/* 启动配置解析器 + * + * 优先读 services.yaml(结构化声明),不存在则回退 init.txt(纯命令列表)。 + * 两种格式都输出 std::vector,调用方无需关心来源。 + */ +class InitParser { +public: + /* 入口:传入配置目录(如 /etc/Nidus) + * + * 查找顺序: + * 1. /services.yaml → parseYaml() + * 2. /init.txt → parseTxt() + * 3. 都不存在 → 返回空 vector,调用方自行处理 + */ + static std::vector load(const std::string& dir); + + /* 解析 services.yaml */ + static std::vector parseYaml(const std::string& path); + + /* 解析 init.txt(向后兼容,每行一条命令,# 开头为注释) */ + static std::vector parseTxt(const std::string& path); +}; + +} // namespace qing::registry diff --git a/core/services.yaml b/core/services.yaml new file mode 100644 index 0000000..0ea5489 --- /dev/null +++ b/core/services.yaml @@ -0,0 +1,87 @@ +# core/services.yaml +# 声明式服务启动配置(替代 init.txt) +# 如果本文件存在则优先使用,否则回退 init.txt +# +# 字段说明: +# name 服务唯一标识(用于日志和未来注册中心) +# cmd 启动命令(与 init.txt 每行格式一致) +# args 传递给 _run.sh 的命令行参数列表(可选) +# env 启动前设置的环境变量(可选) +# depends_on 依赖的服务名列表(未来 DAG 拓扑排序用,当前仅记录) +# sleep_before 启动前等待秒数(替代散落在 _run.sh 中的 sleep) + +services: + # --- 数据层(最先启动) --- + - name: db2 + cmd: bash /opt/micro_servers/db2/_run.sh + args: [] + depends_on: [] + sleep_before: 0 + + # --- AI 审核 --- + - name: ai_review + cmd: bash /opt/micro_servers/ai_review/_run.sh + args: [] + depends_on: [db2] + sleep_before: 2 + + # --- 文件服务 --- + - name: dnfs2 + cmd: bash /opt/micro_servers/dnfs2/_run.sh + args: [] + depends_on: [db2, ai_review] + sleep_before: 0 + + # --- 邮件服务 --- + - name: smtp + cmd: bash /opt/micro_servers/smtp/_run.sh + args: [] + depends_on: [] + sleep_before: 2 + + # --- 前端静态资源 --- + - name: fsys + cmd: bash /opt/micro_servers/fsys/_run.sh + args: [] + depends_on: [] + sleep_before: 0 + + # --- 安全链路 --- + - name: waf + cmd: bash /opt/micro_servers/waf/_run.sh + args: [] # -c config.yaml 保留在 _run.sh 作为默认值 + depends_on: [ca, gateway] + sleep_before: 0 + + - name: ca + cmd: bash /opt/micro_servers/ca/_run.sh + args: [] + depends_on: [db2] + sleep_before: 2 + + - name: gateway + cmd: bash /opt/micro_servers/gateway/_run.sh + args: [] + depends_on: [db2] + sleep_before: 2 + + # --- 验证码 --- + - name: captcha2 + cmd: bash /opt/micro_servers/captcha2/_run.sh + args: [] + depends_on: [] + sleep_before: 1 + + # --- AI 工具箱 --- + - name: aibox + cmd: bash /opt/micro_servers/aibox/_run.sh + args: [] + depends_on: [] + sleep_before: 0 + + # --- 端口重定向(最后启动) --- + - name: redirect80 + cmd: bash /opt/micro_servers/redirect80/_run.sh + args: [] + depends_on: [waf] + sleep_before: 0 diff --git a/core/src/main.cpp b/core/src/main.cpp index b6ec8c0..6500642 100644 --- a/core/src/main.cpp +++ b/core/src/main.cpp @@ -149,7 +149,7 @@ int main(int argc, char** argv) { ("list", "list process all run") /* 列出运行中的进程 */ ("kill", po::value(), "kill a process") /* 杀死一个进程 */ /********* 子系统服务模式 ********/ - ("init,i", po::value(), "self-init list") /* 输入自启动列表 */ + ("init,i", po::value(), "self-init config (dir or .yaml/.txt file)") /* 输入自启动配置 */ ("serve", po::value(), "Run as http server") /* 启用http伺服器 */ ("confs", po::value(), "Run embedded confs rpc server"); /* 启用内嵌confs配置服务 */ diff --git a/core/src/main/pmc_mtd.cpp b/core/src/main/pmc_mtd.cpp index 936c126..68a264a 100644 --- a/core/src/main/pmc_mtd.cpp +++ b/core/src/main/pmc_mtd.cpp @@ -5,6 +5,12 @@ #include #include "main/PPool.hh" #include "main/pmc_mtd.hh" +#include "registry/InitParser.hh" + +#include +#include +#include +#include namespace qing { @@ -87,37 +93,61 @@ catch(std::exception& exp) { /* 解析自启动列表、添加自启动项 * - * 如果在解析过程中出错,只是打印异常? */ -void parse_self_init_list(std::string &path) + * 兼容两种格式: + * - services.yaml(结构化声明,含 args/env/depends_on/sleep_before) + * - init.txt(纯命令列表,每行一条 bash 命令,# 开头为注释) + * + * path 可以是: + * - 目录路径 → 自动查找 services.yaml 或 init.txt + * - .yaml 文件 → 按 YAML 解析 + * - 其他文件 → 按 TXT 解析(向后兼容 init.txt) + * + * 如果在解析过程中出错,只是打印异常 */ +void parse_self_init_list(std::string &path) try { + namespace fs = std::filesystem; - std::ifstream file(path); - if (!file.is_open()) { /* ---- 没能打开自启动文件 ---- */ - std::cerr << "Failed to open self-init list" << std::endl; + std::vector services; + + if (fs::is_directory(path)) { + /* 目录模式:InitParser::load 自动查找 services.yaml → init.txt */ + services = registry::InitParser::load(path); + } else if (path.size() > 5 && path.substr(path.size() - 5) == ".yaml") { + /* YAML 文件模式 */ + services = registry::InitParser::parseYaml(path); + } else { + /* TXT 文件模式(向后兼容 init.txt) */ + services = registry::InitParser::parseTxt(path); + } + + if (services.empty()) { + std::cerr << "parse_self_init_list: 没有可启动的服务" << std::endl; return; } - std::string row = ""; - char ch; - while (file.get(ch)) - - if (ch == '\n') { - - auto pos = row.find("#"); - if (pos != -1) - row = row.substr(0, pos); - if (row != "") { /* trim() */ - ppool.clr(); - ppool.crtp(row); /* 创建进程 */ - row = ""; - } + for (const auto& svc : services) { + /* 启动前等待(替代散落在 _run.sh 中的 sleep) */ + if (svc.sleep_before > 0) { + std::cerr << "[pmc] " << svc.name + << " 等待 " << svc.sleep_before << "s..." << std::endl; + std::this_thread::sleep_for( + std::chrono::seconds(svc.sleep_before)); } - else row += ch; + /* 设置环境变量 */ + for (const auto& [k, v] : svc.env) + setenv(k.c_str(), v.c_str(), 1); - file.close(); + /* 清理已停止的进程,然后创建新进程 */ + ppool.clr(); + std::string full_cmd = svc.toCommand(); + std::cerr << "[pmc] 启动: " << svc.name + << " -> " << full_cmd << std::endl; + ppool.crtp(full_cmd); + } } + catch (std::exception &exp) { std::cerr << "Exception at: parse_self_init_list()" << std::endl; std::cerr << "Exception type: " << typeid(exp).name() << std::endl; diff --git a/core/src/registry/InitParser.cpp b/core/src/registry/InitParser.cpp new file mode 100644 index 0000000..4320fd0 --- /dev/null +++ b/core/src/registry/InitParser.cpp @@ -0,0 +1,131 @@ +#include "registry/InitParser.hh" + +#include +#include +#include +#include +#include + +namespace qing::registry { + +/* -------- ServiceConfig -------- */ + +std::string ServiceConfig::toCommand() const { + std::ostringstream oss; + oss << cmd; + for (const auto& a : args) { + oss << ' ' << a; + } + return oss.str(); +} + +/* -------- InitParser -------- */ + +std::vector InitParser::load(const std::string& dir) { + namespace fs = std::filesystem; + + auto yaml_path = fs::path(dir) / "services.yaml"; + auto txt_path = fs::path(dir) / "init.txt"; + + /* 优先 services.yaml */ + if (fs::exists(yaml_path)) { + std::cerr << "[InitParser] 使用 services.yaml: " << yaml_path << std::endl; + return parseYaml(yaml_path.string()); + } + + /* 回退 init.txt */ + if (fs::exists(txt_path)) { + std::cerr << "[InitParser] 回退 init.txt: " << txt_path << std::endl; + return parseTxt(txt_path.string()); + } + + std::cerr << "[InitParser] 未找到 services.yaml 或 init.txt 于 " << dir << std::endl; + return {}; +} + +std::vector InitParser::parseYaml(const std::string& path) { + std::vector result; + + YAML::Node root; + try { + root = YAML::LoadFile(path); + } catch (const std::exception& e) { + std::cerr << "[InitParser] YAML 解析失败: " << e.what() << std::endl; + return {}; + } + + if (!root["services"] || !root["services"].IsSequence()) { + std::cerr << "[InitParser] services.yaml 缺少 services 序列" << std::endl; + return {}; + } + + for (const auto& node : root["services"]) { + ServiceConfig svc; + + if (node["name"]) + svc.name = node["name"].as(); + + if (node["cmd"]) + svc.cmd = node["cmd"].as(); + + if (node["args"] && node["args"].IsSequence()) + for (const auto& a : node["args"]) + svc.args.push_back(a.as()); + + if (node["env"] && node["env"].IsMap()) + for (const auto& kv : node["env"]) + svc.env[kv.first.as()] = kv.second.as(); + + if (node["depends_on"] && node["depends_on"].IsSequence()) + for (const auto& d : node["depends_on"]) + svc.depends_on.push_back(d.as()); + + if (node["sleep_before"]) + svc.sleep_before = node["sleep_before"].as(0); + + if (svc.cmd.empty()) { + std::cerr << "[InitParser] 跳过缺少 cmd 的服务: " << svc.name << std::endl; + continue; + } + + result.push_back(std::move(svc)); + } + + std::cerr << "[InitParser] 解析完成,共 " << result.size() << " 个服务" << std::endl; + return result; +} + +std::vector InitParser::parseTxt(const std::string& path) { + std::vector result; + + std::ifstream file(path); + if (!file.is_open()) { + std::cerr << "[InitParser] 无法打开 init.txt: " << path << std::endl; + return {}; + } + + std::string line; + while (std::getline(file, line)) { + /* 去除注释(# 之后的内容) */ + auto pos = line.find('#'); + if (pos != std::string::npos) + line = line.substr(0, pos); + + /* trim 空白 */ + auto first = line.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) + continue; /* 空行 */ + auto last = line.find_last_not_of(" \t\r\n"); + line = line.substr(first, last - first + 1); + + ServiceConfig svc; + svc.cmd = line; + /* init.txt 模式下 name/args/env/depends_on/sleep_before 均为空/默认 */ + result.push_back(std::move(svc)); + } + + std::cerr << "[InitParser] init.txt 解析完成,共 " << result.size() << " 个服务" << std::endl; + return result; +} + +} // namespace qing::registry diff --git a/docs/reports/core_services_yaml_dev_doc.md b/docs/reports/core_services_yaml_dev_doc.md new file mode 100644 index 0000000..7e2e13e --- /dev/null +++ b/docs/reports/core_services_yaml_dev_doc.md @@ -0,0 +1,806 @@ +# Core 启动配置 YAML 化与 CLI 参数标准化 · 开发文档 + +> 文档类型:功能开发计划 +> 模块:core(PMC / Nidus)+ 全部子服务 +> 作者:自动生成 +> 日期:2026-08-10 +> 前置关系:本文档是 `core_service_registry_dev_doc.md` P7 阶段(services.yaml)的前置拆分,聚焦"配置格式迁移 + 参数透传 + CLI 标准化"三步基础设施改造 + +--- + +## 1. 概述 + +### 1.1 背景与动机 + +当前 core 通过 `init.txt`(纯命令列表)拉起 11 个子服务,存在以下问题: + +| 现状 | 问题 | +|------|------| +| `init.txt` 每行一条 bash 命令,无结构化字段 | 无法为每个服务指定命令行参数、环境变量、依赖关系 | +| 5/11 个 `_run.sh` 不转发 `"$@"` | core 即便想传参也传不进去 | +| `sleep 2` 等依赖等待逻辑散落在各 `_run.sh` 中 | 无统一管理,调整需逐个脚本修改 | +| waf 的 `-c config.yaml` 硬编码在 `_run.sh` | 参数不可外部覆盖 | +| 各服务 CLI 接口不统一 | ca/gateway 仅有 `--help/--version`,waf 有 `-c/-list/-test/-check`,其余 8 个服务零 CLI 支持 | + +**本方案目标**:通过三步改造,让 core 能够以结构化 YAML 声明每个服务的启动命令和参数,参数能透传到各服务二进制/脚本,并建立统一的 CLI 参数规范——为注册中心 `services.yaml` + `depends_on` DAG 拓扑排序打下基础。 + +### 1.2 设计目标 + +1. **结构化启动声明**:`init.txt` → `services.yaml`,每个服务可声明 `cmd`、`args`、`env`、`depends_on`、`sleep_before` +2. **参数透传**:全部 11 个 `_run.sh` 统一转发 `"$@"`,消除硬编码参数 +3. **CLI 标准化**:定义通用参数约定(`--host`/`--port`/`--config`/`--log-level`/`--instance`),各服务逐步适配 +4. **向后兼容**:`services.yaml` 不存在时回退 `init.txt`,现有部署零中断 +5. **yaml-cpp 引入**:vendored 到 `core/third_party/yaml-cpp/`,不依赖系统包管理器 + +### 1.3 与注册中心计划的关系 + +``` +本文档(三步改造) 注册中心计划(core_service_registry_dev_doc.md) +┌─────────────────────┐ ┌─────────────────────────────┐ +│ Step 1: services.yaml│ ──────▶ │ P7: services.yaml DAG 排序 │ +│ Step 2: _run.sh 透传 │ ──────▶ │ P2: init 解析后自动填充注册表 │ +│ Step 3: CLI 标准化 │ ──────▶ │ P5: RegistrationClient SDK │ +└─────────────────────┘ └─────────────────────────────┘ +``` + +本方案是注册中心的**基础设施前置**——做完这三步,P2/P5/P7 的工作量直接减半。 + +--- + +## 2. 现状分析 + +### 2.1 init.txt 当前内容 + +``` +bash /opt/micro_servers/db2/_run.sh +bash /opt/micro_servers/ai_review/_run.sh +bash /opt/micro_servers/dnfs2/_run.sh +bash /opt/micro_servers/smtp/_run.sh +bash /opt/micro_servers/fsys/_run.sh +bash /opt/micro_servers/waf/_run.sh +bash /opt/micro_servers/ca/_run.sh +bash /opt/micro_servers/gateway/_run.sh +bash /opt/micro_servers/captcha2/_run.sh +bash /opt/micro_servers/aibox/_run.sh +bash /opt/micro_servers/redirect80/_run.sh +``` + +纯命令列表,无参数、无元数据、无依赖声明。 + +### 2.2 各服务 _run.sh 现状 + +| 服务 | 语言 | `"$@"` 转发 | 硬编码参数 | 特殊处理 | sleep 等待 | +|------|------|-------------|-----------|----------|------------| +| db2 | C++ | ✅ `"$@"` | — | `set -e` | — | +| dnfs2 | C++ | ✅ `"$@"` | — | `set -e` | — | +| fsys | C++ | ✅ `"$@"` | — | `set -e` | — | +| ca | C++ | ✅ `"$@"` | — | — | `sleep 2` | +| gateway | C++ | ✅ `"$@"` | — | — | `sleep 2` | +| waf | Go | ❌ | `-c config.yaml` | — | — | +| captcha2 | C++ | ❌ | — | — | `sleep 1` | +| aibox | Python | ⚠️ `$@`(未加引号) | — | `_setenv.sh` + venv | — | +| ai_review | Python | ❌ | — | venv | — | +| smtp | Python | ❌ | — | venv | `sleep 2` | +| redirect80 | Python | ❌ | — | setcap + venv | — | + +**统计**:5 个已转发、1 个半转发(未加引号)、5 个未转发。3 个脚本含 `sleep` 硬编码。 + +### 2.3 各服务 CLI 参数支持现状 + +| 服务 | 语言 | CLI 框架 | 实际参数 | 配置来源 | +|------|------|----------|----------|----------| +| db2 | C++ | 无 | 无 | 硬编码常量 | +| dnfs2 | C++ | 无 | 无 | 硬编码 + confs | +| fsys | C++ | 无 | 无 | 硬编码 | +| captcha2 | C++ | 无 | 无 | 硬编码 | +| ca | C++ | `boost::program_options` | `--help` `--version`(仅此) | confs (ca.ini) + 环境变量 | +| gateway | C++ | `boost::program_options` | `--help` `--version`(仅此) | confs (gateway.ini) + 环境变量 | +| waf | Go | `flag` | `-c` `-list` `-test` `-check` | YAML 配置文件 | +| aibox | Python | `argparse`(已 import 未使用) | 无 | `_setenv.sh` + 硬编码 | +| ai_review | Python | 无 | 无 | 硬编码 | +| smtp | Python | 无 | 无 | 硬编码 | +| redirect80 | Python | 无 | 无 | 硬编码 | + +**关键发现**: +- ca 和 gateway 已引入 `boost::program_options` 但只实现了 `--help`/`--version`,实际配置全走 confs + 环境变量 +- aibox 已 `import argparse` 但从未调用,是死代码 +- waf 是唯一有完整 CLI 接口的服务(Go `flag` 包) +- 8/11 个服务零 CLI 参数支持 + +### 2.4 配置加载模式分类 + +当前各服务的配置获取有三种模式: + +| 模式 | 服务 | 说明 | +|------|------|------| +| **confs + 环境变量** | ca, gateway, dnfs2 | 从 confs 服务拉取 ini 配置,环境变量可覆盖;`CONFS_HOST`/`CONFS_PORT` 引导 | +| **本地配置文件** | waf | `-c config.yaml` 指定 YAML 配置 | +| **硬编码** | db2, fsys, captcha2, aibox, ai_review, smtp, redirect80 | 常量直接写在源码中 | + +CLI 标准化后,三种模式可统一为:**CLI 参数(最高优先级)> 环境变量 > confs/配置文件 > 硬编码默认值**。 + +--- + +## 3. 三步改造方案 + +### 3.1 总览 + +``` +Step 1: init.txt → services.yaml Step 2: _run.sh 参数透传 Step 3: CLI 参数标准化 +┌──────────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ +│ • 引入 yaml-cpp (vendored) │ │ • 11 个 _run.sh 统一 │ │ • 定义通用参数约定 │ +│ • 设计 services.yaml 格式 │ ──▶ │ 末尾 exec 加 "$@" │ ──▶ │ • _run.sh 注入默认值 │ +│ • core 解析 YAML 并启动 │ │ • 移除硬编码参数 │ │ • 各服务逐步适配 │ +│ • 回退兼容 init.txt │ │ • sleep 移至 YAML │ │ • 环境变量桥接 │ +└──────────────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +--- + +## 4. Step 1:init.txt → services.yaml + +### 4.1 yaml-cpp 引入 + +采用系统包安装方式(已写入 `_setup.deb.sh` 和 `_setup.rhel.sh`): + +| 发行版 | 包名 | 安装命令 | +|--------|------|----------| +| Debian/Ubuntu | `libyaml-cpp-dev` | `apt install libyaml-cpp-dev` | +| RHEL/CentOS/Rocky | `yaml-cpp-devel` | `dnf install yaml-cpp-devel` | + +**Makefile 集成**(已完成): + +```makefile +# 链接 yaml-cpp 系统库 +LIBS := ... -lyaml-cpp + +# InitParser 编译目标 +$(BUILD_DIR)/InitParser.o: src/registry/InitParser.cpp + $(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@ + +# 链接时追加 +$(APP_NAME).out: ... $(BUILD_DIR)/InitParser.o +``` + +### 4.2 services.yaml 格式设计 + +```yaml +# core/services.yaml +# 声明式服务启动配置(替代 init.txt) +# 如果本文件存在则优先使用,否则回退 init.txt + +services: + # --- 数据层 --- + - name: db2 + cmd: bash /opt/micro_servers/db2/_run.sh + args: [] + env: {} + depends_on: [] + sleep_before: 0 + + # --- AI 服务 --- + - name: ai_review + cmd: bash /opt/micro_servers/ai_review/_run.sh + args: ["--port", "8007", "--log-level", "info"] + env: + PYTHONUNBUFFERED: "1" + depends_on: [db2] + sleep_before: 2 + + # --- 文件服务 --- + - name: dnfs2 + cmd: bash /opt/micro_servers/dnfs2/_run.sh + args: [] + env: {} + depends_on: [db2, ai_review] + sleep_before: 0 + + # --- 邮件服务 --- + - name: smtp + cmd: bash /opt/micro_servers/smtp/_run.sh + args: [] + env: {} + depends_on: [] + sleep_before: 2 + + # --- 前端静态资源 --- + - name: fsys + cmd: bash /opt/micro_servers/fsys/_run.sh + args: [] + env: {} + depends_on: [] + sleep_before: 0 + + # --- 安全链路 --- + - name: waf + cmd: bash /opt/micro_servers/waf/_run.sh + args: ["-c", "config.yaml"] + env: {} + depends_on: [ca, gateway] + sleep_before: 0 + + - name: ca + cmd: bash /opt/micro_servers/ca/_run.sh + args: [] + env: {} + depends_on: [db2] + sleep_before: 2 + + - name: gateway + cmd: bash /opt/micro_servers/gateway/_run.sh + args: [] + env: {} + depends_on: [db2] + sleep_before: 2 + + # --- 验证码 --- + - name: captcha2 + cmd: bash /opt/micro_servers/captcha2/_run.sh + args: [] + env: {} + depends_on: [] + sleep_before: 1 + + # --- AI 工具箱 --- + - name: aibox + cmd: bash /opt/micro_servers/aibox/_run.sh + args: [] + env: + PATH: "${PATH}:~/.aibox/skills" + depends_on: [] + sleep_before: 0 + + # --- 端口重定向 --- + - name: redirect80 + cmd: bash /opt/micro_servers/redirect80/_run.sh + args: [] + env: {} + depends_on: [waf] + sleep_before: 0 +``` + +**字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 服务唯一标识,用于日志和未来注册表 | +| `cmd` | string | 是 | 启动命令(与 init.txt 每行格式一致) | +| `args` | string[] | 否 | 传递给 `_run.sh` 的命令行参数列表 | +| `env` | map | 否 | 启动前设置的环境变量 | +| `depends_on` | string[] | 否 | 依赖的服务名列表(未来 DAG 排序用,当前仅记录) | +| `sleep_before` | int | 否 | 启动前等待秒数(替代散落在 _run.sh 中的 sleep) | + +### 4.3 core 解析逻辑 + +新增 `include/registry/InitParser.hh` + `src/registry/InitParser.cpp`: + +```cpp +// include/registry/InitParser.hh +#pragma once +#include +#include +#include + +namespace qing::registry { + +struct ServiceConfig { + std::string name; + std::string cmd; + std::vector args; + std::map env; + std::vector depends_on; + int sleep_before = 0; +}; + +class InitParser { +public: + // 优先读 services.yaml,不存在则回退 init.txt + static std::vector load(const std::string& conf_dir); + + // 解析 services.yaml + static std::vector parseYaml(const std::string& path); + + // 解析 init.txt(向后兼容) + static std::vector parseTxt(const std::string& path); + + // 将 ServiceConfig 拼成完整命令字符串(cmd + " " + args.join(" ")) + static std::string toCommand(const ServiceConfig& svc); +}; + +} // namespace qing::registry +``` + +**解析流程**: + +``` +load(conf_dir): + 1. 检查 conf_dir/services.yaml 是否存在 + 2. 存在 → parseYaml() → 返回 ServiceConfig 列表 + 3. 不存在 → 检查 conf_dir/init.txt + 4. 存在 → parseTxt() → 每行作为 ServiceConfig{name="", cmd=line, args=[]} + 5. 都不存在 → 报错退出 +``` + +**parseYaml 核心逻辑**(使用 yaml-cpp): + +```cpp +#include + +std::vector InitParser::parseYaml(const std::string& path) { + YAML::Node root = YAML::LoadFile(path); + std::vector result; + + for (const auto& node : root["services"]) { + ServiceConfig svc; + svc.name = node["name"].as(""); + svc.cmd = node["cmd"].as(); + + if (node["args"]) + for (const auto& a : node["args"]) + svc.args.push_back(a.as()); + + if (node["env"]) + for (const auto& kv : node["env"]) + svc.env[kv.first.as()] = kv.second.as(); + + if (node["depends_on"]) + for (const auto& d : node["depends_on"]) + svc.depends_on.push_back(d.as()); + + if (node["sleep_before"]) + svc.sleep_before = node["sleep_before"].as(0); + + result.push_back(std::move(svc)); + } + return result; +} +``` + +**启动执行逻辑**(改动 `pmc_mtd::parse_self_init_list`): + +```cpp +// pmc_mtd.cpp 中改造 +void pmc_mtd::init_from_config(const std::string& conf_dir) { + auto services = InitParser::load(conf_dir); + + for (const auto& svc : services) { + // 1. sleep_before 等待 + if (svc.sleep_before > 0) + std::this_thread::sleep_for(std::chrono::seconds(svc.sleep_before)); + + // 2. 设置环境变量 + for (const auto& [k, v] : svc.env) + setenv(k.c_str(), v.c_str(), 1); + + // 3. 拼接完整命令并启动 + std::string full_cmd = InitParser::toCommand(svc); + // full_cmd = "bash /opt/micro_servers/db2/_run.sh --port 8007 --log-level info" + ppool.crtp(full_cmd); + } +} +``` + +### 4.4 向后兼容策略 + +``` + services.yaml 存在? + │ + ┌─── 是 ──┤── 否 ───┐ + ▼ ▼ + parseYaml() init.txt 存在? + │ │ + │ ┌── 是 ──┤── 否 ───┐ + │ ▼ ▼ + │ parseTxt() 报错退出 + │ │ + └─────┬─────┘ + ▼ + ServiceConfig[] + 逐个启动 +``` + +- `init.txt` 模式下,每行解析为 `{name="", cmd=line, args=[], sleep_before=0}`,行为与当前完全一致 +- `services.yaml` 模式下,享受参数透传和 sleep 管理能力 +- Makefile 的 `CONF_NAME` 可逐步从 `init.txt` 切换为 `services.yaml` + +--- + +## 5. Step 2:_run.sh 参数透传 + +### 5.1 改造规则 + +每个 `_run.sh` 的末尾 `exec` 行必须带 `"$@"`,同时移除硬编码参数和散落的 `sleep`。 + +**改造模板**: + +```bash +#!/bin/bash +# /_run.sh — 标准化启动脚本 +cd "$(dirname "$0")" || exit + +# --- 服务特有前置(venv / setcap 等)保持不变 --- + +# 启动:"$@" 透传 core/services.yaml 传入的参数 +exec "$@" +``` + +### 5.2 逐服务改造清单 + +| 服务 | 当前 exec 行 | 改造后 | 额外改动 | +|------|-------------|--------|----------| +| db2 | `exec ./build/db2 "$@"` | ✅ 无需改 | — | +| dnfs2 | `exec ./build/dnfs2 "$@"` | ✅ 无需改 | — | +| fsys | `exec ./build/fsys "$@"` | ✅ 无需改 | — | +| ca | `exec ./build/ca.out "$@"` | ✅ 无需改 | 移除 `sleep 2`(移交 YAML) | +| gateway | `exec ./build/gateway.out "$@"` | ✅ 无需改 | 移除 `sleep 2`(移交 YAML) | +| waf | `exec ./waf.bin -c config.yaml` | `exec ./waf.bin -c config.yaml "$@"` | 保留 `-c config.yaml` 作为脚本默认值,services.yaml `args: []` | +| captcha2 | `exec ./captcha2.bin` | `exec ./captcha2.bin "$@"` | 移除 `sleep 1`(移交 YAML) | +| aibox | `exec python aibox.py $@` | `exec python aibox.py "$@"` | `$@` → `"$@"` 加引号 | +| ai_review | `exec python3 ai_review.py` | `exec python3 ai_review.py "$@"` | — | +| smtp | `exec python3 smtp_v2.py` | `exec python3 smtp_v2.py "$@"` | 移除 `sleep 2`(移交 YAML) | +| redirect80 | `exec python3 redirect80.py` | `exec python3 redirect80.py "$@"` | — | + +**统计**:6 个无需改、5 个需改 exec 行、4 个需移除 sleep。 + +### 5.3 sleep 迁移对照 + +| 服务 | 当前 sleep 位置 | 迁移到 services.yaml | +|------|----------------|---------------------| +| ca | `_run.sh` 中 `sleep 2` | `sleep_before: 2` | +| gateway | `_run.sh` 中 `sleep 2` | `sleep_before: 2` | +| captcha2 | `_run.sh` 中 `sleep 1` | `sleep_before: 1` | +| smtp | `_run.sh` 中 `sleep 2` | `sleep_before: 2` | + +移除后 `_run.sh` 只负责"切换目录 + 前置准备 + exec",不负责时序控制。 + +--- + +## 6. Step 3:CLI 参数标准化 + +### 6.1 通用参数约定 + +定义一组所有服务都应支持的通用 CLI 参数: + +| 参数 | 短选项 | 类型 | 默认值 | 说明 | +|------|--------|------|--------|------| +| `--host` | `-H` | string | `127.0.0.1` | 绑定地址 | +| `--port` | `-p` | int | 服务特定 | 监听端口 | +| `--config` | `-c` | string | 服务特定 | 配置文件路径 | +| `--log-level` | `-l` | enum | `info` | `debug\|info\|warn\|error` | +| `--instance` | `-i` | string | 服务名 | 实例标识(未来多实例/注册中心用) | +| `--help` | `-h` | flag | — | 输出帮助 | +| `--version` | `-v` | flag | — | 输出版本 | + +**优先级规则**:`CLI 参数 > 环境变量 > confs/配置文件 > 硬编码默认值` + +### 6.2 _run.sh 默认值注入 + +在服务二进制还不支持所有参数的过渡期,`_run.sh` 可注入默认值: + +```bash +#!/bin/bash +# waf/_run.sh — 标准化后 +cd "$(dirname "$0")" || exit + +# 默认参数(可被 services.yaml args 覆盖) +DEFAULT_ARGS=(-c config.yaml) + +exec ./waf.bin "${DEFAULT_ARGS[@]}" "$@" +``` + +```bash +#!/bin/bash +# db2/_run.sh — 标准化后 +cd "$(dirname "$0")" || exit +set -e + +# 默认参数 +DEFAULT_ARGS=(--host 127.0.0.1 --port 8005 --log-level info) + +exec ./build/db2 "${DEFAULT_ARGS[@]}" "$@" +``` + +**原理**:`"$@"` 追加在 `DEFAULT_ARGS` 之后,如果用户通过 services.yaml 传了同名参数,取决于各服务的解析逻辑(`boost::program_options` 默认后者覆盖前者;Python `argparse` 同理)。 + +### 6.3 各语言适配指南 + +#### C++ 服务(db2, dnfs2, fsys, captcha2) + +当前这 4 个服务无 CLI 框架。建议引入 `boost::program_options`(ca/gateway 已有先例): + +```cpp +// db2/src/db2.cpp — 新增 CLI 解析 +#include +namespace po = boost::program_options; + +int main(int argc, char** argv) { + po::options_description desc("db2 — database RPC service"); + desc.add_options() + ("help,h", "output help message") + ("version,v", "output version message") + ("host,H", po::value()->default_value("127.0.0.1"), "bind address") + ("port,p", po::value()->default_value(8005), "listen port") + ("log-level,l", po::value()->default_value("info"), + "debug|info|warn|error") + ("instance,i", po::value()->default_value("db2"), "instance name") + ; + + po::variables_map vm; + try { + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + } catch (const std::exception& e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << desc << std::endl; + return 1; + } + + if (vm.count("help")) { std::cout << desc << std::endl; return 0; } + if (vm.count("version")) { std::cout << "db2 1.0.0" << std::endl; return 0; } + + std::string host = vm["host"].as(); + int port = vm["port"].as(); + std::string log_level = vm["log-level"].as(); + + // 后续使用 host/port/log_level 启动服务... +} +``` + +#### C++ 服务(ca, gateway)— 扩展现有 program_options + +ca 和 gateway已有 `po::options_description` 但只注册了 `--help`/`--version`,需追加通用参数: + +```cpp +// ca/src/main.cpp — 扩展 +desc.add_options() + ("help,h", "output help message") + ("version,v", "output version message") + // 新增通用参数 + ("host,H", po::value()->default_value("127.0.0.1"), "bind address") + ("port,p", po::value()->default_value(8443), "listen port") + ("log-level,l", po::value()->default_value("info"), + "debug|info|warn|error") + ("instance,i", po::value()->default_value("ca"), "instance name") +; +``` + +注意:ca/gateway 当前配置走 confs + 环境变量,CLI 参数应作为**覆盖层**,不替换 confs 机制。 + +#### Go 服务(waf)— 扩展现有 flag + +waf 已有 `flag` 包,追加通用参数: + +```go +func main() { + var ( + configFile = flag.String("c", "config.yaml", "配置文件路径") + host = flag.String("H", "127.0.0.1", "绑定地址") + port = flag.Int("p", 443, "监听端口") + logLevel = flag.String("l", "info", "debug|info|warn|error") + instance = flag.String("i", "waf", "实例名") + doList = flag.Bool("list", false, "列出所有规则后退出") + doTest = flag.String("test", "", "校验单个规则文件语法后退出") + doCheck = flag.String("check", "", "用当前规则测试输入字符串后退出") + ) + flag.Parse() + // ... +} +``` + +注意:Go `flag` 包不支持短选项连写(`-H 127.0.0.1`),与 C++ `program_options` 的 `-H` 语法兼容。 + +#### Python 服务(aibox, ai_review, smtp, redirect80) + +建议统一使用 `argparse`: + +```python +#!/usr/bin/env python3 +"""ai_review — AI 审核服务""" +import argparse + +def parse_args(): + parser = argparse.ArgumentParser(description="ai_review — AI 审核服务") + parser.add_argument("-H", "--host", default="127.0.0.1", help="绑定地址") + parser.add_argument("-p", "--port", type=int, default=8007, help="监听端口") + parser.add_argument("-l", "--log-level", default="info", + choices=["debug", "info", "warn", "error"], help="日志级别") + parser.add_argument("-i", "--instance", default="ai_review", help="实例名") + return parser.parse_args() + +if __name__ == "__main__": + args = parse_args() + # 后续使用 args.host, args.port, args.log_level... +``` + +aibox 已有 `import argparse`(死代码),激活即可。 + +### 6.4 环境变量桥接 + +对于走 confs + 环境变量的服务(ca, gateway, dnfs2),CLI 参数应映射到环境变量,保持与现有 confs 机制兼容: + +```bash +# ca/_run.sh — 环境变量桥接模式 +cd "$(dirname "$0")" || exit + +# 从命令行参数提取并导出环境变量(供 confs 加载逻辑使用) +while [[ $# -gt 0 ]]; do + case "$1" in + -H|--host) export CA_HOST="$2"; shift 2 ;; + -p|--port) export CA_PORT="$2"; shift 2 ;; + -l|--log-level) export LOG_LEVEL="$2"; shift 2 ;; + *) EXTRA_ARGS+=("$1"); shift ;; + esac +done + +exec ./build/ca.out "${EXTRA_ARGS[@]}" +``` + +这种桥接方式不需要立刻改 ca 的 C++ 源码——`_run.sh` 先把 CLI 参数翻译成环境变量,ca 现有的环境变量读取逻辑自然生效。 + +--- + +## 7. 实施计划 + +### 7.1 分步交付 + +| 步骤 | 改动范围 | 涉及文件 | 预估工时 | 风险 | +|------|----------|----------|----------|------| +| **S1-a** | yaml-cpp vendored + Makefile 集成 | `core/third_party/yaml-cpp/`、`core/Makefile` | 1h | 低 | +| **S1-b** | InitParser 实现 | `core/include/registry/InitParser.hh`、`core/src/registry/InitParser.cpp` | 2h | 低 | +| **S1-c** | pmc_mtd 改造(调用 InitParser) | `core/src/main/pmc_mtd.cpp` | 1h | 中(改核心启动路径) | +| **S1-d** | 创建 services.yaml | `core/services.yaml` | 0.5h | 低 | +| **S1-e** | 回退测试(删除 services.yaml 验证 init.txt 兼容) | — | 0.5h | 低 | +| **S2-a** | 5 个 _run.sh 改 exec 行 | `waf/`、`captcha2/`、`aibox/`、`ai_review/`、`smtp/`、`redirect80/` 的 `_run.sh` | 0.5h | 极低 | +| **S2-b** | 4 个 _run.sh 移除 sleep | `ca/`、`gateway/`、`captcha2/`、`smtp/` 的 `_run.sh` | 0.2h | 低(sleep 已移交 YAML) | +| **S3-a** | 定义 CLI 标准文档(本文档 §6) | — | 0h(已完成) | — | +| **S3-b** | C++ 服务适配(ca/gateway 扩展,db2/dnfs2/fsys/captcha2 新增) | 各服务 `src/*.cpp` | 4h | 中 | +| **S3-c** | Python 服务适配(argparse) | `aibox.py`、`ai_review.py`、`smtp_v2.py`、`redirect80.py` | 2h | 低 | +| **S3-d** | Go 服务适配(waf flag 扩展) | `waf/waf.go` | 0.5h | 低 | +| **S3-e** | _run.sh 默认值注入 | 各服务 `_run.sh` | 0.5h | 低 | +| **S4** | 端到端测试 | — | 1h | — | + +**总计预估:~14 小时** + +### 7.2 推荐实施顺序 + +``` +S1-a (yaml-cpp 集成) + └─▶ S1-b (InitParser) + └─▶ S1-c (pmc_mtd 改造) + └─▶ S1-d (services.yaml) + └─▶ S1-e (回退测试) ──▶ Step 1 完成 + │ + ▼ + S2-a + S2-b ( _run.sh 改造) ──▶ Step 2 完成 + │ + ▼ + S3-b/c/d (各服务 CLI 适配) + └─▶ S3-e (默认值注入) ──▶ Step 3 完成 + │ + ▼ + S4 (端到端测试) +``` + +### 7.3 新增/改动文件清单 + +``` +core/ +├── include/registry/ +│ └── InitParser.hh # 【新增】YAML/TXT 解析器头文件 +├── src/registry/ +│ └── InitParser.cpp # 【新增】YAML/TXT 解析器实现 +├── src/main/pmc_mtd.cpp # 【改动】调用 InitParser 替代 parse_self_init_list +├── src/main.cpp # 【改动】--init 帮助文本更新 +├── Makefile # 【改动】追加 InitParser.o 编译目标 + -lyaml-cpp 链接 +├── _run.sh # 【改动】-i init.txt → -i .(目录模式自动查找) +├── services.yaml # 【新增】声明式服务启动配置 +└── init.txt # 【保留】向后兼容回退 + +项目根目录/ +├── _setup.deb.sh # 【改动】追加 libyaml-cpp-dev +└── _setup.rhel.sh # 【改动】追加 yaml-cpp-devel + +各服务 _run.sh(11 个) # 【已完成】Step 2 统一 "$@" 透传 + 移除 sleep + +C++ 服务源码(6 个) # 【待改】Step 3 新增/扩展 program_options +Python 服务源码(4 个) # 【待改】Step 3 新增 argparse +Go 服务源码(1 个) # 【待改】Step 3 扩展 flag +``` + +--- + +## 8. 验证清单 + +### 8.1 Step 1 验证 + +- [ ] `make` 编译成功,yaml-cpp 源文件正确编译链接 +- [ ] `services.yaml` 存在时,core 按 YAML 声明启动全部 11 个服务 +- [ ] `services.yaml` 不存在时,core 回退 `init.txt`,行为与改造前完全一致 +- [ ] `args` 字段的参数正确透传到 `_run.sh` +- [ ] `env` 字段的环境变量在子进程启动前生效 +- [ ] `sleep_before` 字段正确控制启动间隔 + +### 8.2 Step 2 验证 + +- [x] 全部 11 个 `_run.sh` 末尾 `exec` 行带 `"$@"` +- [x] waf 保留 `-c config.yaml` 作为脚本默认值,追加 `"$@"` 透传,services.yaml `args: []` +- [x] aibox 的 `$@` 改为 `"$@"`(加引号,防空格路径问题) +- [x] ca/gateway/captcha2/smtp 的 `sleep` 从 `_run.sh` 移除,由 `services.yaml` 的 `sleep_before` 管理 +- [ ] 手动执行 `_run.sh --help` 能透传到二进制(依赖 Step 3 CLI 适配) + +### 8.3 Step 3 验证 + +- [ ] 全部服务支持 `--help` 输出用法 +- [ ] 全部服务支持 `--version` 输出版本 +- [ ] `--port` 能覆盖默认端口并生效 +- [ ] `--log-level` 能控制日志输出级别 +- [ ] `_run.sh` 默认值注入 + `services.yaml` 覆盖,两者叠加不冲突 +- [ ] ca/gateway 的环境变量桥接模式正确工作 + +--- + +## 9. 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| yaml-cpp 编译增加构建时间 | 低(~10s) | 可接受;如需优化可预编译为静态库 | +| pmc_mtd 改造影响核心启动路径 | 高 | 保留 `init.txt` 回退路径;先在测试环境验证 | +| _run.sh 移除 sleep 后启动时序变化 | 中 | `services.yaml` 的 `sleep_before` 提供等价等待 | +| 各服务 CLI 参数适配工作量大 | 中 | 分批适配;_run.sh 默认值注入可过渡 | +| Go flag 与 C++ program_options 语法差异 | 低 | 两者都支持 `-H value` 和 `--host value` 格式 | +| Python argparse 与 C++ program_options 行为差异 | 低 | 测试覆盖;argparse 默认不覆盖已设默认值 | + +--- + +## 10. 附录 + +### A. 各服务端口与配置对照 + +| 服务 | 端口 | 协议 | 配置来源 | CLI 框架现状 | +|------|------|------|----------|-------------| +| db2 | 8005 | pymsg | 硬编码 | 无 | +| ai_review | 8007 | http | 硬编码 | 无 | +| dnfs2 | 8006 | http | 硬编码 + confs | 无 | +| smtp | 8008 | pymsg | 硬编码 | 无 | +| fsys | 8090 | http | 硬编码 | 无 | +| waf | 443 | https | YAML 配置文件 | Go flag ✅ | +| ca | 8443 | https | confs + 环境变量 | program_options(仅 --help/--version) | +| gateway | 8445 | http | confs + 环境变量 | program_options(仅 --help/--version) | +| captcha2 | 8009 | pymsg | 硬编码 | 无 | +| aibox | 8765 | websocket | _setenv.sh + 硬编码 | argparse(已 import 未使用) | +| redirect80 | 80 | http | 硬编码 | 无 | + +### B. yaml-cpp 集成验证 + +已采用系统包方式(非 vendored),在 `_setup.deb.sh` / `_setup.rhel.sh` 中声明依赖: + +```bash +# Debian/Ubuntu +sudo apt install libyaml-cpp-dev + +# RHEL/CentOS +sudo dnf install yaml-cpp-devel +``` + +Makefile 中 `-lyaml-cpp` 链接,`InitParser.cpp` 中 `#include ` 引用。 + +### C. 环境变量约定(现有 + 新增) + +| 环境变量 | 使用方 | 当前/新增 | 说明 | +|----------|--------|-----------|------| +| `CONFS_HOST` | ca, gateway, dnfs2 | 现有 | confs 服务地址,默认 `127.0.0.1` | +| `CONFS_PORT` | ca, gateway, dnfs2 | 现有 | confs 服务端口,默认 `8010` | +| `CORE_HOST` | 未来 RegistrationClient | 新增 | core 注册中心地址,默认 `127.0.0.1` | +| `CORE_PORT` | 未来 RegistrationClient | 新增 | core 注册中心端口,默认 `5200` | +| `LOG_LEVEL` | 全部服务(可选) | 新增 | 日志级别,可被 `--log-level` 覆盖 | + +### D. 与注册中心计划的衔接点 + +| 本方案产出 | 注册中心计划消费点 | +|-----------|-------------------| +| `services.yaml` 格式 | P7 直接复用,追加 `health_endpoint`/`protocol`/`restart_max` 等字段 | +| `InitParser` 解析器 | P2 调用 `parseYaml()` 后自动填充 `ServiceRegistry` | +| `ServiceConfig.depends_on` | P7 DAG 拓扑排序的输入 | +| `ServiceConfig.args` | 各服务通过 CLI 参数接收注册中心分配的端口/实例名 | +| CLI `--instance` 参数 | P5 RegistrationClient SDK 用此值作为注册服务名 | +| CLI `--port` 参数 | P0 ServiceEntry 的 port 字段来源 | diff --git a/gateway/_run.sh b/gateway/_run.sh index 9c34359..b9b5ba8 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -4,7 +4,5 @@ # 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 cd "$(dirname "$0")" || exit -# 等待依赖服务启动(confs/db) -sleep 2 - +# sleep 已移至 services.yaml 的 sleep_before 字段统一管理 exec ./build/gateway.out "$@" diff --git a/redirect80/_run.sh b/redirect80/_run.sh index d181884..28c65cf 100755 --- a/redirect80/_run.sh +++ b/redirect80/_run.sh @@ -13,4 +13,4 @@ if command -v setcap &>/dev/null; then fi fi -exec python3 redirect80.py +exec python3 redirect80.py "$@" diff --git a/smtp/_run.sh b/smtp/_run.sh index 0811c15..40ca352 100755 --- a/smtp/_run.sh +++ b/smtp/_run.sh @@ -2,5 +2,4 @@ cd "$(dirname "$0")" || exit source ./venv/bin/activate #exec ./smtp.bin -sleep 2 -exec python3 smtp_v2.py +exec python3 smtp_v2.py "$@" diff --git a/waf/_run.sh b/waf/_run.sh index 260fb9f..844c116 100644 --- a/waf/_run.sh +++ b/waf/_run.sh @@ -1,4 +1,4 @@ #!/bin/bash # 运行 WAF(需先 _set.sh 编译) cd "$(dirname "$0")" || exit -exec ./waf.bin -c config.yaml +exec ./waf.bin -c config.yaml "$@" From ac177c73df39aa909ea50b6ee94435d901365fe4 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:19:00 +0800 Subject: [PATCH 018/196] =?UTF-8?q?feat(fsys):=20CLI=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=87=E5=87=86=E5=8C=96=20=E2=80=94=20--host/--log-level/--?= =?UTF-8?q?instance/--config/--help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host 作为 --addr 的规范化别名(两者完全等价) - 新增 --log-level debug|info|warn|error,实现日志级别过滤 * _log() 按 Config.log_level 过滤,低于设定级别的日志直接丢弃 * 兼容现有大写 level 字符串(INFO/WARNING/ERROR) - 新增 --instance 指定实例名(注册中心用,默认 fsys) - 新增 --config 覆盖 confs 拉取的配置名(默认 static.ini) - 新增 --help/-h 打印用法并退出 - Config 优先级不变:CLI > env > confs > 默认值 - 更新启动输出包含 instance/log-level/config 信息 --- fsys/src/fsys.cpp | 76 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/fsys/src/fsys.cpp b/fsys/src/fsys.cpp index d3eece8..b8723cf 100644 --- a/fsys/src/fsys.cpp +++ b/fsys/src/fsys.cpp @@ -14,7 +14,7 @@ * 安全改进(相比 Python static): * F-1 server.setMaxBodySize(10MB) 限制请求体大小 * F-2 libhttp 原生 HTTPS 支持,无需回退开发服务器 - * F-3 命令行参数 --addr/-a --port/-p --threads/-t + * F-3 命令行参数 --host/--addr/-a --port/-p --threads/-t --log-level --instance --config --help * F-4 所有端口/线程数有严格边界校验 * F-5 Boost.Beast 异步多线程替代 Flask 同步 * F-6 消除 Python/Flask/waitress 依赖 @@ -271,11 +271,40 @@ struct Config { int db_port = 8005; std::string dnfs_url = "http://127.0.0.1:8006"; + + // CLI 标准化参数(Step 3) + std::string log_level = "info"; // debug|info|warn|error + std::string instance_name = "fsys"; // 实例名(注册中心用) + std::string confs_config_name = "static.ini"; // confs 拉取的配置名 }; static Config load_config(int argc, char* argv[]) { Config cfg; + // 0. 预扫描 --help 和 --config(需在 confs 拉取前处理) + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + std::printf( + "fsys — C++20 静态文件托管服务\n\n" + "Usage: fsys [OPTIONS]\n\n" + "Options:\n" + " -h, --help Show this help and exit\n" + " -a, --addr Listen address (alias: --host)\n" + " --host Listen address (canonical name)\n" + " -p, --port Listen port\n" + " -t, --threads Worker threads\n" + " --config confs config name (default: static.ini)\n" + " --log-level debug|info|warn|error (default: info)\n" + " --instance Instance name (default: fsys)\n\n" + "Config priority: CLI > env > confs > defaults\n"); + std::exit(0); + } + if (arg == "--config" && i + 1 < argc) { + cfg.confs_config_name = argv[++i]; + } + } + // 1. confs 引导地址 if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; if (const char* p = std::getenv("CONFS_PORT")) { @@ -286,17 +315,18 @@ static Config load_config(int argc, char* argv[]) { } } - // 2. 从 confs 拉取 static.ini(retry_on_refused=true) + // 2. 从 confs 拉取配置(retry_on_refused=true) std::string ini_text; try { RpcClient confs_client(cfg.confs_host, cfg.confs_port, "fsys", /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", "static.ini"}}); + json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); if (data.is_object() && data.as_object().contains("content")) { ini_text = std::string(data.as_object().at("content").as_string()); } } catch (const std::exception& e) { - std::fprintf(stderr, "[fsys] 从 confs 获取 static.ini 失败: %s\n", e.what()); + std::fprintf(stderr, "[fsys] 从 confs 获取 %s 失败: %s\n", + cfg.confs_config_name.c_str(), e.what()); std::exit(1); } @@ -343,9 +373,12 @@ static Config load_config(int argc, char* argv[]) { // 5. 命令行参数(最高优先级) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; - if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i]; + if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads"); + else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; + else if (arg == "--instance" && i + 1 < argc) cfg.instance_name = argv[++i]; + // --config 和 --help 已在预扫描阶段处理 } // 6. 校验必需配置项 @@ -372,6 +405,18 @@ static Config load_config(int argc, char* argv[]) { std::exit(1); } + // log_level 校验 + { + std::string ll = cfg.log_level; + std::transform(ll.begin(), ll.end(), ll.begin(), ::tolower); + if (ll != "debug" && ll != "info" && ll != "warn" && ll != "error") { + std::fprintf(stderr, "[fsys] invalid --log-level: %s (must be debug|info|warn|error)\n", + cfg.log_level.c_str()); + std::exit(1); + } + cfg.log_level = ll; + } + // 8. 路径规范化:static_root/SSL 相对路径基于当前工作目录 // (_run.sh 以模块目录为 cwd 启动,与 Python 版 BASE_DIR 语义一致; // Docker 场景使用绝对路径 STATIC_ROOT,不受影响) @@ -654,8 +699,11 @@ public: _filesystem_enabled = false; } - std::printf("[fsys] 配置来源: confs (static.ini @ %s:%d)\n", + std::printf("[fsys] 配置来源: confs (%s @ %s:%d)\n", + _cfg.confs_config_name.c_str(), _cfg.confs_host.c_str(), _cfg.confs_port); + std::printf("[fsys] 实例: %s | 日志级别: %s\n", + _cfg.instance_name.c_str(), _cfg.log_level.c_str()); std::printf("[fsys] 静态目录: %s\n", _cfg.static_root.c_str()); } @@ -687,6 +735,19 @@ private: void _log(const std::string& level, const std::string& service, const std::string& message, const std::string& trace_id = "", const std::string& extra = "") { + // 日志级别过滤 + static const std::unordered_map kLogLevels = { + {"debug", 0}, {"DEBUG", 0}, + {"info", 1}, {"INFO", 1}, + {"warn", 2}, {"WARNING", 2}, + {"error", 3}, {"ERROR", 3} + }; + auto cfg_it = kLogLevels.find(_cfg.log_level); + auto msg_it = kLogLevels.find(level); + if (cfg_it != kLogLevels.end() && msg_it != kLogLevels.end()) { + if (msg_it->second < cfg_it->second) return; + } + LogEntry e; e.ts = utc_now(); e.service = service; @@ -1576,6 +1637,9 @@ int main(int argc, char* argv[]) { cfg.static_root.c_str(), cfg.ssl_enabled ? "on" : "off"); std::printf("[fsys] db=%s:%d dnfs=%s\n", cfg.db_host.c_str(), cfg.db_port, cfg.dnfs_url.c_str()); + std::printf("[fsys] instance=%s log-level=%s config=%s\n", + cfg.instance_name.c_str(), cfg.log_level.c_str(), + cfg.confs_config_name.c_str()); std::fflush(stdout); FsysServer server(std::move(cfg)); From 391e6b0066b89240c79f5a90899ee1a7fb8c3ce9 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:27:12 +0800 Subject: [PATCH 019/196] =?UTF-8?q?feat(db2):=20CLI=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=87=E5=87=86=E5=8C=96=20=E2=80=94=20--host/--log-level/--?= =?UTF-8?q?instance/--config/--help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host 作为 --addr 的规范化别名(两者完全等价) - 新增 --log-level debug|info|warn|error,实现日志级别过滤 * _log_internal() 按 Config.log_level 过滤,低于设定级别的日志直接丢弃 * 兼容现有大写 level 字符串(INFO) - 新增 --instance 指定实例名(注册中心用,默认 db2) - 新增 --config 覆盖 confs 拉取的配置名(默认 db.ini) - 新增 --help/-h 打印用法并退出 - Config 优先级不变:CLI > env > confs > 默认值 - 更新启动输出包含 instance/log-level/config 信息 --- db2/src/db2.cpp | 68 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/db2/src/db2.cpp b/db2/src/db2.cpp index e7fe404..53879e3 100644 --- a/db2/src/db2.cpp +++ b/db2/src/db2.cpp @@ -145,6 +145,11 @@ struct Config { // confs 地址(引导配置,不从 confs 自身获取) std::string confs_host = "127.0.0.1"; int confs_port = 8010; + + // 标准化 CLI 参数 + std::string log_level = "info"; // debug|info|warn|error + std::string instance = "db2"; // 实例名(注册中心用) + std::string confs_config_name = "db.ini"; // confs 拉取的配置名 }; // 从 sqlite:///path 或 sqlite:////abs/path 提取文件路径 @@ -159,21 +164,43 @@ static std::string db_url_to_path(const std::string& url) { static Config load_config(int argc, char* argv[]) { Config cfg; + // 0. 预扫描 --help/-h 和 --config(需在 confs 拉取前处理) + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + std::printf( + "db2 — C++20 数据库 RPC 服务\n\n" + "用法: db2 [选项]\n\n" + "选项:\n" + " --host 绑定地址 (别名: --addr/-a)\n" + " --port 监听端口 (别名: -p)\n" + " --workers 工作线程数 (别名: -w)\n" + " --config confs 配置名 (默认: db.ini)\n" + " --log-level debug|info|warn|error (默认: info)\n" + " --instance 实例名 (默认: db2)\n" + " --help, -h 显示帮助并退出\n\n" + "配置优先级: CLI > 环境变量 > confs > 默认值\n"); + std::exit(0); + } + if (arg == "--config" && i + 1 < argc) cfg.confs_config_name = argv[++i]; + } + // 1. 从环境变量读取 confs 引导地址 if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; if (const char* p = std::getenv("CONFS_PORT")) cfg.confs_port = safe_stoi(p, "CONFS_PORT"); - // 2. 从 confs 服务拉取 db.ini(retry_on_refused=true,等待 confs 就绪) + // 2. 从 confs 服务拉取配置(retry_on_refused=true,等待 confs 就绪) std::string ini_text; try { RpcClient confs_client(cfg.confs_host, cfg.confs_port, "db2", /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", "db.ini"}}); + json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); if (data.is_object() && data.as_object().contains("content")) { ini_text = std::string(data.as_object().at("content").as_string()); } } catch (const std::exception& e) { - std::fprintf(stderr, "[db2] Failed to fetch db.ini from confs: %s\n", e.what()); + std::fprintf(stderr, "[db2] Failed to fetch %s from confs: %s\n", + cfg.confs_config_name.c_str(), e.what()); std::exit(1); } @@ -223,9 +250,12 @@ static Config load_config(int argc, char* argv[]) { // 5. 命令行参数(最高优先级) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; - if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i]; + if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); else if ((arg == "--workers" || arg == "-w") && i + 1 < argc) cfg.workers = safe_stoi(argv[++i], "--workers"); + else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; + else if (arg == "--instance" && i + 1 < argc) cfg.instance = argv[++i]; + // --config 和 --help 已在预扫描阶段处理 } // 6. 数值配置范围校验(防误配置导致异常行为) @@ -250,6 +280,18 @@ static Config load_config(int argc, char* argv[]) { std::exit(1); } + // log_level 合法性校验 + { + std::string lv = cfg.log_level; + std::transform(lv.begin(), lv.end(), lv.begin(), ::tolower); + if (lv != "debug" && lv != "info" && lv != "warn" && lv != "error") { + std::fprintf(stderr, "[db2] invalid log-level: %s (must be debug|info|warn|error)\n", + cfg.log_level.c_str()); + std::exit(1); + } + cfg.log_level = lv; + } + return cfg; } @@ -892,6 +934,18 @@ private: void _log_internal(const std::string& level, const std::string& service, std::string message, const std::string& trace_id, const std::string& extra) { + // 日志级别过滤 + static const std::unordered_map kLogLevels = { + {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, {"error", 3}, + {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, {"ERROR", 3} + }; + auto it_cfg = kLogLevels.find(_cfg.log_level); + auto it_msg = kLogLevels.find(level); + if (it_cfg != kLogLevels.end() && it_msg != kLogLevels.end() + && it_msg->second < it_cfg->second) { + return; // 低于设定级别,丢弃 + } + if (message.size() > kMaxMessage) message.resize(kMaxMessage); std::string ex = extra; if (ex.size() > kMaxExtra) ex.resize(kMaxExtra); @@ -992,9 +1046,11 @@ int main(int argc, char* argv[]) { try { Config cfg = load_config(argc, argv); - std::printf("[db2] listen=%s:%d workers=%d db=%s\n", + std::printf("[db2] listen=%s:%d workers=%d db=%s instance=%s log-level=%s config=%s\n", cfg.listen_host.c_str(), cfg.listen_port, - cfg.workers, cfg.db_url.c_str()); + cfg.workers, cfg.db_url.c_str(), + cfg.instance.c_str(), cfg.log_level.c_str(), + cfg.confs_config_name.c_str()); std::fflush(stdout); DB2Server server(cfg); From 14971971a1e25933b196d91960a4c70eefc35393 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:29:08 +0800 Subject: [PATCH 020/196] =?UTF-8?q?chore(services.yaml):=20=E4=B8=BA=20db2?= =?UTF-8?q?/fsys=20=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20--instance=20?= =?UTF-8?q?=E5=92=8C=20--log-level=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已支持标准化 CLI 的服务(db2/fsys)在 args 中显式传入 --instance 和 --log-level,使 services.yaml 成为配置的唯一事实来源。 其余服务待 CLI 标准化后逐步补充。 --- core/services.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 0ea5489..a6177af 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -14,7 +14,7 @@ services: # --- 数据层(最先启动) --- - name: db2 cmd: bash /opt/micro_servers/db2/_run.sh - args: [] + args: ["--instance", "db2", "--log-level", "info"] depends_on: [] sleep_before: 0 @@ -42,7 +42,7 @@ services: # --- 前端静态资源 --- - name: fsys cmd: bash /opt/micro_servers/fsys/_run.sh - args: [] + args: ["--instance", "fsys", "--log-level", "info"] depends_on: [] sleep_before: 0 From fc4e1c4a6185f68c039065863840fc7e4c01f897 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:32:58 +0800 Subject: [PATCH 021/196] =?UTF-8?q?feat(dnfs2):=20CLI=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=87=E5=87=86=E5=8C=96=20=E2=80=94=20--host/--log-level/--?= =?UTF-8?q?instance/--config/--help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host 作为 --addr 的规范化别名(两者完全等价) - 新增 --log-level debug|info|warn|error,实现日志级别过滤 * _log() 按 Config.log_level 过滤,低于设定级别的日志直接丢弃 * 兼容现有大写 level 字符串(INFO/WARNING/ERROR) - 新增 --instance 指定实例名(注册中心用,默认 dnfs2) - 新增 --config 覆盖 confs 拉取的配置名(默认 dnfs.ini) - 新增 --help/-h 打印用法并退出 - Config 优先级不变:CLI > env > confs > 默认值 - services.yaml 同步声明 --instance 和 --log-level - 更新启动输出包含 instance/log-level/config 信息 --- core/services.yaml | 2 +- dnfs2/src/dnfs2.cpp | 71 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index a6177af..68a9bb2 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -28,7 +28,7 @@ services: # --- 文件服务 --- - name: dnfs2 cmd: bash /opt/micro_servers/dnfs2/_run.sh - args: [] + args: ["--instance", "dnfs2", "--log-level", "info"] depends_on: [db2, ai_review] sleep_before: 0 diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp index 9874dac..0ef0e23 100644 --- a/dnfs2/src/dnfs2.cpp +++ b/dnfs2/src/dnfs2.cpp @@ -166,11 +166,37 @@ struct Config { // confs 引导 std::string confs_host = "127.0.0.1"; int confs_port = 8010; + + // CLI 标准化参数 + std::string instance = "dnfs2"; + std::string log_level = "info"; // debug|info|warn|error + std::string confs_config_name = "dnfs.ini"; // --config 覆盖 }; static Config load_config(int argc, char* argv[]) { Config cfg; + // 0. 预扫描 --help/-h 和 --config(在 confs 拉取前处理) + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + std::printf( + "dnfs2 — C++20 树状网络文件系统\n\n" + "用法: dnfs2 [选项]\n\n" + "选项:\n" + " --host 绑定地址(别名: --addr/-a),默认 127.0.0.1\n" + " --port 监听端口(别名: -p),默认 8006\n" + " --threads 工作线程数(别名: -t),默认 4\n" + " --config confs 拉取的配置名,默认 dnfs.ini\n" + " --log-level debug|info|warn|error,默认 info\n" + " --instance 实例名(注册中心用),默认 dnfs2\n" + " --help, -h 显示此帮助并退出\n\n" + "配置优先级: CLI > 环境变量 > confs > 默认值\n"); + std::exit(0); + } + if (arg == "--config" && i + 1 < argc) cfg.confs_config_name = argv[++i]; + } + // 1. confs 引导地址 if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; if (const char* p = std::getenv("CONFS_PORT")) { @@ -181,17 +207,17 @@ static Config load_config(int argc, char* argv[]) { } } - // 2. 从 confs 拉取 dnfs.ini(retry_on_refused=true,等待 confs 就绪) + // 2. 从 confs 拉取配置文件(retry_on_refused=true,等待 confs 就绪) std::string ini_text; try { RpcClient confs_client(cfg.confs_host, cfg.confs_port, "dnfs2", /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", "dnfs.ini"}}); + json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); if (data.is_object() && data.as_object().contains("content")) { ini_text = std::string(data.as_object().at("content").as_string()); } } catch (const std::exception& e) { - std::fprintf(stderr, "[dnfs2] 从 confs 获取 dnfs.ini 失败: %s\n", e.what()); + std::fprintf(stderr, "[dnfs2] 从 confs 获取 %s 失败: %s\n", cfg.confs_config_name.c_str(), e.what()); std::exit(1); } @@ -247,9 +273,11 @@ static Config load_config(int argc, char* argv[]) { // 5. 命令行参数(最高优先级) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; - if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i]; + if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads"); + else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; + else if (arg == "--instance" && i + 1 < argc) cfg.instance = argv[++i]; } // 6. 数值范围校验 @@ -278,6 +306,18 @@ static Config load_config(int argc, char* argv[]) { std::exit(1); } + // log_level 合法性校验 + { + std::string lv = cfg.log_level; + if (lv == "warning") lv = "warn"; + if (lv != "debug" && lv != "info" && lv != "warn" && lv != "error") { + std::fprintf(stderr, "[dnfs2] invalid log_level: %s (must be debug|info|warn|error)\n", + cfg.log_level.c_str()); + std::exit(1); + } + cfg.log_level = lv; + } + return cfg; } @@ -499,8 +539,11 @@ public: ); _log_queue->start(); - std::printf("[dnfs2] 配置来源: confs (dnfs.ini @ %s:%d)\n", + std::printf("[dnfs2] 配置来源: confs (%s @ %s:%d)\n", + _cfg.confs_config_name.c_str(), _cfg.confs_host.c_str(), _cfg.confs_port); + std::printf("[dnfs2] instance=%s log-level=%s\n", + _cfg.instance.c_str(), _cfg.log_level.c_str()); std::printf("[dnfs2] Token 验签: gateway %s\n", _cfg.gateway_url.c_str()); } @@ -567,6 +610,19 @@ private: void _log(const std::string& level, const std::string& service, const std::string& message, const std::string& trace_id = "", const std::string& extra = "") { + // 日志级别过滤 + static const std::unordered_map kLogLevels = { + {"debug", 0}, {"DEBUG", 0}, + {"info", 1}, {"INFO", 1}, + {"warn", 2}, {"WARN", 2}, {"warning", 2}, {"WARNING", 2}, + {"error", 3}, {"ERROR", 3} + }; + auto it_lvl = kLogLevels.find(level); + int msg_lvl = (it_lvl != kLogLevels.end()) ? it_lvl->second : 1; + auto it_cfg = kLogLevels.find(_cfg.log_level); + int cfg_lvl = (it_cfg != kLogLevels.end()) ? it_cfg->second : 1; + if (msg_lvl < cfg_lvl) return; + LogEntry e; e.ts = utc_now(); e.service = service; @@ -1182,8 +1238,9 @@ int main(int argc, char* argv[]) { try { Config cfg = load_config(argc, argv); - std::printf("[dnfs2] listen=%s:%d threads=%d\n", - cfg.listen_host.c_str(), cfg.listen_port, cfg.threads); + std::printf("[dnfs2] listen=%s:%d threads=%d instance=%s\n", + cfg.listen_host.c_str(), cfg.listen_port, cfg.threads, + cfg.instance.c_str()); std::printf("[dnfs2] db=%s:%d ai_review=%s gateway=%s\n", cfg.db_host.c_str(), cfg.db_port, cfg.review_url.c_str(), cfg.gateway_url.c_str()); From 22ade06114a316a656441b3d1bfff0b9b90ffd01 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:35:39 +0800 Subject: [PATCH 022/196] =?UTF-8?q?dnfs2:=20--no-confs=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20+=20=E5=85=A8=E9=87=8F=20CLI=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=EF=BC=88dnfs.ini=20=E5=AD=97=E6=AE=B5=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E5=8F=AF=20CLI=20=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --db-host/--db-port/--db-timeout - 新增 --review-url/--review-timeout - 新增 --gateway-url/--gateway-timeout - 新增 --no-confs 开关:跳过 confs 拉取,完全靠 CLI + ENV + 默认值 - services.yaml: dnfs2 启用 --no-confs,所有配置写进 args --- core/services.yaml | 25 +++++++- dnfs2/src/dnfs2.cpp | 147 ++++++++++++++++++++++++++------------------ 2 files changed, 110 insertions(+), 62 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 68a9bb2..5a27311 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -28,7 +28,30 @@ services: # --- 文件服务 --- - name: dnfs2 cmd: bash /opt/micro_servers/dnfs2/_run.sh - args: ["--instance", "dnfs2", "--log-level", "info"] + args: + - "--no-confs" + - "--instance" + - "dnfs2" + - "--log-level" + - "info" + - "--host" + - "127.0.0.1" + - "--port" + - "8006" + - "--db-host" + - "127.0.0.1" + - "--db-port" + - "8005" + - "--db-timeout" + - "10" + - "--review-url" + - "http://127.0.0.1:8007/review" + - "--review-timeout" + - "10" + - "--gateway-url" + - "http://127.0.0.1:8443/whoami" + - "--gateway-timeout" + - "5" depends_on: [db2, ai_review] sleep_before: 0 diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp index 0ef0e23..2cb5b9b 100644 --- a/dnfs2/src/dnfs2.cpp +++ b/dnfs2/src/dnfs2.cpp @@ -171,6 +171,7 @@ struct Config { std::string instance = "dnfs2"; std::string log_level = "info"; // debug|info|warn|error std::string confs_config_name = "dnfs.ini"; // --config 覆盖 + bool no_confs = false; // --no-confs: 跳过 confs 拉取 }; static Config load_config(int argc, char* argv[]) { @@ -184,17 +185,27 @@ static Config load_config(int argc, char* argv[]) { "dnfs2 — C++20 树状网络文件系统\n\n" "用法: dnfs2 [选项]\n\n" "选项:\n" - " --host 绑定地址(别名: --addr/-a),默认 127.0.0.1\n" - " --port 监听端口(别名: -p),默认 8006\n" - " --threads 工作线程数(别名: -t),默认 4\n" - " --config confs 拉取的配置名,默认 dnfs.ini\n" - " --log-level debug|info|warn|error,默认 info\n" - " --instance 实例名(注册中心用),默认 dnfs2\n" - " --help, -h 显示此帮助并退出\n\n" - "配置优先级: CLI > 环境变量 > confs > 默认值\n"); + " --host 绑定地址(别名: --addr/-a),默认 127.0.0.1\n" + " --port 监听端口(别名: -p),默认 8006\n" + " --threads 工作线程数(别名: -t),默认 4\n" + " --db-host 数据库地址,默认 127.0.0.1\n" + " --db-port 数据库端口,默认 8005\n" + " --db-timeout 数据库超时秒数,默认 10\n" + " --review-url AI 审核服务 URL,默认 http://127.0.0.1:8007/review\n" + " --review-timeout AI 审核超时秒数,默认 10\n" + " --gateway-url 网关验签 URL,默认 http://127.0.0.1:8443/whoami\n" + " --gateway-timeout 网关超时秒数,默认 5\n" + " --config confs 拉取的配置名,默认 dnfs.ini\n" + " --no-confs 跳过 confs 拉取,完全依赖 CLI + ENV + 默认值\n" + " --log-level debug|info|warn|error,默认 info\n" + " --instance 实例名(注册中心用),默认 dnfs2\n" + " --help, -h 显示此帮助并退出\n\n" + "配置优先级: CLI > 环境变量 > confs > 默认值\n" + "使用 --no-confs 时跳过 confs,优先级变为: CLI > 环境变量 > 默认值\n"); std::exit(0); } if (arg == "--config" && i + 1 < argc) cfg.confs_config_name = argv[++i]; + if (arg == "--no-confs") cfg.no_confs = true; } // 1. confs 引导地址 @@ -208,60 +219,63 @@ static Config load_config(int argc, char* argv[]) { } // 2. 从 confs 拉取配置文件(retry_on_refused=true,等待 confs 就绪) - std::string ini_text; - try { - RpcClient confs_client(cfg.confs_host, cfg.confs_port, "dnfs2", - /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); - if (data.is_object() && data.as_object().contains("content")) { - ini_text = std::string(data.as_object().at("content").as_string()); - } - } catch (const std::exception& e) { - std::fprintf(stderr, "[dnfs2] 从 confs 获取 %s 失败: %s\n", cfg.confs_config_name.c_str(), e.what()); - std::exit(1); - } - - // 3. 解析 INI - IniMap ini = parse_ini(ini_text); - - // 校验必需配置项 - static const std::pair required_keys[] = { - {"server", "host"}, - {"server", "port"}, - {"database", "host"}, - {"database", "port"}, - {"database", "timeout"}, - {"ai_review", "url"}, - {"ai_review", "timeout"}, - {"gateway", "url"}, - {"gateway", "timeout"}, - }; - for (auto& [sec, key] : required_keys) { - auto si = ini.find(sec); - if (si == ini.end() || si->second.find(key) == si->second.end()) { - std::fprintf(stderr, "[dnfs2] 缺少必需配置: [%s] %s\n", sec, key); + // --no-confs 模式下跳过,直接用 ENV + CLI + 默认值 + if (!cfg.no_confs) { + std::string ini_text; + try { + RpcClient confs_client(cfg.confs_host, cfg.confs_port, "dnfs2", + /*timeout=*/5.0, /*retry_on_refused=*/true); + json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); + if (data.is_object() && data.as_object().contains("content")) { + ini_text = std::string(data.as_object().at("content").as_string()); + } + } catch (const std::exception& e) { + std::fprintf(stderr, "[dnfs2] 从 confs 获取 %s 失败: %s\n", cfg.confs_config_name.c_str(), e.what()); std::exit(1); } + + // 3. 解析 INI + IniMap ini = parse_ini(ini_text); + + // 校验必需配置项 + static const std::pair required_keys[] = { + {"server", "host"}, + {"server", "port"}, + {"database", "host"}, + {"database", "port"}, + {"database", "timeout"}, + {"ai_review", "url"}, + {"ai_review", "timeout"}, + {"gateway", "url"}, + {"gateway", "timeout"}, + }; + for (auto& [sec, key] : required_keys) { + auto si = ini.find(sec); + if (si == ini.end() || si->second.find(key) == si->second.end()) { + std::fprintf(stderr, "[dnfs2] 缺少必需配置: [%s] %s\n", sec, key); + std::exit(1); + } + } + + auto get = [&](const std::string& sec, const std::string& key, + const std::string& def = "") -> std::string { + auto si = ini.find(sec); + if (si == ini.end()) return def; + auto ki = si->second.find(key); + return (ki == si->second.end()) ? def : ki->second; + }; + + cfg.listen_host = get("server", "host", cfg.listen_host); + cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); + cfg.db_host = get("database", "host", cfg.db_host); + cfg.db_port = safe_stoi(get("database", "port", std::to_string(cfg.db_port)), "[database] port"); + cfg.db_timeout = std::stod(get("database", "timeout", std::to_string(cfg.db_timeout))); + cfg.review_url = get("ai_review", "url", cfg.review_url); + cfg.review_timeout = std::stod(get("ai_review", "timeout", std::to_string(cfg.review_timeout))); + cfg.gateway_url = get("gateway", "url", cfg.gateway_url); + cfg.gateway_timeout = std::stod(get("gateway", "timeout", std::to_string(cfg.gateway_timeout))); } - auto get = [&](const std::string& sec, const std::string& key, - const std::string& def = "") -> std::string { - auto si = ini.find(sec); - if (si == ini.end()) return def; - auto ki = si->second.find(key); - return (ki == si->second.end()) ? def : ki->second; - }; - - cfg.listen_host = get("server", "host", cfg.listen_host); - cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); - cfg.db_host = get("database", "host", cfg.db_host); - cfg.db_port = safe_stoi(get("database", "port", std::to_string(cfg.db_port)), "[database] port"); - cfg.db_timeout = std::stod(get("database", "timeout", std::to_string(cfg.db_timeout))); - cfg.review_url = get("ai_review", "url", cfg.review_url); - cfg.review_timeout = std::stod(get("ai_review", "timeout", std::to_string(cfg.review_timeout))); - cfg.gateway_url = get("gateway", "url", cfg.gateway_url); - cfg.gateway_timeout = std::stod(get("gateway", "timeout", std::to_string(cfg.gateway_timeout))); - // 4. 环境变量覆盖(Docker 兼容) if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); @@ -276,6 +290,13 @@ static Config load_config(int argc, char* argv[]) { if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads"); + else if (arg == "--db-host" && i + 1 < argc) cfg.db_host = argv[++i]; + else if (arg == "--db-port" && i + 1 < argc) cfg.db_port = safe_stoi(argv[++i], "--db-port"); + else if (arg == "--db-timeout" && i + 1 < argc) cfg.db_timeout = std::stod(argv[++i]); + else if (arg == "--review-url" && i + 1 < argc) cfg.review_url = argv[++i]; + else if (arg == "--review-timeout" && i + 1 < argc) cfg.review_timeout = std::stod(argv[++i]); + else if (arg == "--gateway-url" && i + 1 < argc) cfg.gateway_url = argv[++i]; + else if (arg == "--gateway-timeout" && i + 1 < argc) cfg.gateway_timeout = std::stod(argv[++i]); else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; else if (arg == "--instance" && i + 1 < argc) cfg.instance = argv[++i]; } @@ -539,9 +560,13 @@ public: ); _log_queue->start(); - std::printf("[dnfs2] 配置来源: confs (%s @ %s:%d)\n", - _cfg.confs_config_name.c_str(), - _cfg.confs_host.c_str(), _cfg.confs_port); + if (cfg.no_confs) { + std::printf("[dnfs2] 配置来源: CLI + ENV + 默认值(--no-confs 模式)\n"); + } else { + std::printf("[dnfs2] 配置来源: confs (%s @ %s:%d)\n", + _cfg.confs_config_name.c_str(), + _cfg.confs_host.c_str(), _cfg.confs_port); + } std::printf("[dnfs2] instance=%s log-level=%s\n", _cfg.instance.c_str(), _cfg.log_level.c_str()); std::printf("[dnfs2] Token 验签: gateway %s\n", _cfg.gateway_url.c_str()); From 01e67648e084537a92fc1f3ad417be63a1e8018f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 03:39:10 +0800 Subject: [PATCH 023/196] =?UTF-8?q?dnfs2:=20=E5=BD=BB=E5=BA=95=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20dnfs.ini=20=E4=BE=9D=E8=B5=96=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E7=BA=AF=20CLI=20+=20ENV=20+=20=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 confs/configs/dnfs.ini - dnfs2.cpp: 移除 INI 解析器、confs 拉取逻辑、--config/--no-confs 参数 - dnfs2.cpp: 移除 confs_host/confs_port/confs_config_name/no_confs 字段 - dnfs2.cpp: 移除 // 未使用头文件 - services.yaml: 移除 --no-confs(不再需要) - 更新 dnfs2/README.md: 配置说明改为 CLI 参数表 - 更新 README.md / confs/README.md: 移除 dnfs.ini 引用 --- README.md | 2 +- confs/README.md | 9 --- confs/configs/dnfs.ini | 16 ----- core/services.yaml | 1 - dnfs2/README.md | 38 +++++------ dnfs2/src/dnfs2.cpp | 140 +++-------------------------------------- 6 files changed, 24 insertions(+), 182 deletions(-) delete mode 100644 confs/configs/dnfs.ini diff --git a/README.md b/README.md index 6a1ae0d..8bd81f8 100755 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ - 数据操作:libmsg `RpcClient` 调 db2 `execute` 路由(与 Python 版调 db 路径一致)。 - 内容审核:HttpClient POST ai_review `/review`,fail-closed。 - 异步日志:libmsg `AsyncQueue` 写入 db2 Logs 表。 -- 配置从 confs 拉取 `dnfs.ini`,环境变量覆盖,命令行参数最高优先级;详见 [dnfs2/README.md](dnfs2/README.md)。 +- 配置通过 CLI 参数 + 环境变量 + 默认值,不依赖 confs;详见 [dnfs2/README.md](dnfs2/README.md)。 ### db(通用数据库服务) diff --git a/confs/README.md b/confs/README.md index 7a6a4be..485a99e 100644 --- a/confs/README.md +++ b/confs/README.md @@ -236,7 +236,6 @@ except RpcError as e: | 服务 | 配置文件 | 接入方式 | |------|---------|---------| | [static](../static/) | [static.ini](configs/static.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | -| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | | [db](../db/) | [db.ini](configs/db.ini) | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) | static 的接入实现见 [static/fsys.py](../static/fsys.py): @@ -248,13 +247,6 @@ static 的接入实现见 [static/fsys.py](../static/fsys.py): - 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`STATIC_ROOT`/`CONFS_HOST`/`CONFS_PORT` - 已删除 `--config` 命令行参数和本地 `config.ini` 回退逻辑 -dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py): -- 两层加载顺序:confs(dnfs.ini,强依赖)→ 环境变量覆盖(优先级最高) -- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,不严格要求启动顺序 -- 配置项涵盖:`[server]` host/port、`[database]` host/port/timeout、`[ai_review]` url/timeout -- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010` -- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT` - db 的接入实现见 [db/dbv2.py](../db/dbv2.py): - 两层加载顺序:confs(db.ini,强依赖)→ 环境变量覆盖(优先级最高) - pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,不严格要求启动顺序 @@ -274,7 +266,6 @@ confs/ ├── configs/ # 配置文件存放目录(被分发对象) │ ├── example.ini # 示例配置 │ ├── static.ini # static 服务配置(已接入分发) -│ ├── dnfs.ini # dnfs 服务配置(已接入分发) │ └── db.ini # db 服务配置(已接入分发) ├── docs/ │ └── SECURITY_AUDIT_20260801.md # 安全审计报告 diff --git a/confs/configs/dnfs.ini b/confs/configs/dnfs.ini deleted file mode 100644 index f327890..0000000 --- a/confs/configs/dnfs.ini +++ /dev/null @@ -1,16 +0,0 @@ -[server] -host = 127.0.0.1 -port = 8006 - -[database] -host = 127.0.0.1 -port = 8005 -timeout = 10 - -[ai_review] -url = http://127.0.0.1:8007/review -timeout = 10 - -[gateway] -url = http://127.0.0.1:8443/whoami -timeout = 5 diff --git a/core/services.yaml b/core/services.yaml index 5a27311..2dedd24 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -29,7 +29,6 @@ services: - name: dnfs2 cmd: bash /opt/micro_servers/dnfs2/_run.sh args: - - "--no-confs" - "--instance" - "dnfs2" - "--log-level" diff --git a/dnfs2/README.md b/dnfs2/README.md index 3974ee7..e3ad48d 100644 --- a/dnfs2/README.md +++ b/dnfs2/README.md @@ -33,30 +33,24 @@ dnfs2 :8006 ──pymsg──► db2 :8005 (execute 路由) ──► SQLite ## 配置 -从 confs 拉取 `dnfs.ini`,环境变量覆盖,命令行参数最高优先级。 +纯 CLI + 环境变量 + 默认值,不依赖 confs。 -```ini -[server] -host = 127.0.0.1 -port = 8006 +| 配置项 | CLI 参数 | 环境变量 | 默认值 | +|--------|---------|---------|--------| +| 绑定地址 | `--host` / `--addr` / `-a` | `LISTEN_ADDR` | 127.0.0.1 | +| 监听端口 | `--port` / `-p` | `LISTEN_PORT` | 8006 | +| 工作线程 | `--threads` / `-t` | — | 4 | +| 数据库地址 | `--db-host` | `DB_HOST` | 127.0.0.1 | +| 数据库端口 | `--db-port` | `DB_PORT` | 8005 | +| 数据库超时 | `--db-timeout` | — | 10 | +| AI 审核URL | `--review-url` | `REVIEW_URL` | http://127.0.0.1:8007/review | +| AI 审核超时 | `--review-timeout` | — | 10 | +| 网关验签URL | `--gateway-url` | `GATEWAY_URL` | http://127.0.0.1:8443/whoami | +| 网关超时 | `--gateway-timeout` | — | 5 | +| 日志级别 | `--log-level` | — | info | +| 实例名 | `--instance` | — | dnfs2 | -[database] -host = 127.0.0.1 -port = 8005 -timeout = 10 - -[ai_review] -url = http://127.0.0.1:8007/review -timeout = 10 - -[gateway] -url = http://127.0.0.1:8443/whoami -timeout = 5 -``` - -环境变量: `CONFS_HOST`, `CONFS_PORT`, `LISTEN_ADDR`, `LISTEN_PORT`, `DB_HOST`, `DB_PORT`, `REVIEW_URL`, `GATEWAY_URL` - -命令行: `--addr`, `--port`, `--threads` +优先级: CLI > 环境变量 > 默认值 ## 构建 diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp index 2cb5b9b..ee67d5d 100644 --- a/dnfs2/src/dnfs2.cpp +++ b/dnfs2/src/dnfs2.cpp @@ -9,7 +9,7 @@ * 安全修复(相比 Python dnfs): * P0-4 统一封装 _call_db() 检查 success 后再访问 data * P1-6 server.setMaxBodySize(10MB) 限制请求体大小 - * P1-7 日志客户端复用主配置(confs 拉取的 db host/port) + * P1-7 日志客户端复用主配置(CLI/ENV 的 db host/port) * P1-8 update_note 改 pid≠0 时校验父节点存在 * P1-9 RpcClient 启用 retry_on_refused * P1-11 note_id 显式 as_int64() + 类型校验 @@ -34,14 +34,11 @@ #include #include #include -#include #include #include -#include #include #include #include -#include #include #include #include @@ -106,44 +103,6 @@ static int safe_stoi(const std::string& s, const char* field_name) { } } -// ============================================================================ -// INI 解析(白名单模式,与 db2 一致) -// ============================================================================ - -using IniMap = std::map>; - -static IniMap parse_ini(const std::string& text) { - IniMap result; - std::string section; - std::istringstream ss(text); - std::string line; - while (std::getline(ss, line)) { - auto b = line.find_first_not_of(" \t\r\n"); - if (b == std::string::npos) continue; - line = line.substr(b); - auto e = line.find_last_not_of(" \t\r\n"); - if (e != std::string::npos) line = line.substr(0, e + 1); - - if (line.empty() || line[0] == '#' || line[0] == ';') continue; - if (line.front() == '[' && line.back() == ']') { - section = line.substr(1, line.size() - 2); - continue; - } - auto eq = line.find('='); - if (eq == std::string::npos || section.empty()) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - auto trim = [](std::string& s) { - auto bb = s.find_first_not_of(" \t"); - auto ee = s.find_last_not_of(" \t"); - s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1); - }; - trim(key); trim(val); - result[section][key] = val; - } - return result; -} - // ============================================================================ // 配置 // ============================================================================ @@ -163,21 +122,15 @@ struct Config { std::string gateway_url = "http://127.0.0.1:8443/whoami"; double gateway_timeout = 5.0; - // confs 引导 - std::string confs_host = "127.0.0.1"; - int confs_port = 8010; - // CLI 标准化参数 std::string instance = "dnfs2"; std::string log_level = "info"; // debug|info|warn|error - std::string confs_config_name = "dnfs.ini"; // --config 覆盖 - bool no_confs = false; // --no-confs: 跳过 confs 拉取 }; static Config load_config(int argc, char* argv[]) { Config cfg; - // 0. 预扫描 --help/-h 和 --config(在 confs 拉取前处理) + // 0. 预扫描 --help/-h(在正式解析前处理) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if (arg == "--help" || arg == "-h") { @@ -195,88 +148,15 @@ static Config load_config(int argc, char* argv[]) { " --review-timeout AI 审核超时秒数,默认 10\n" " --gateway-url 网关验签 URL,默认 http://127.0.0.1:8443/whoami\n" " --gateway-timeout 网关超时秒数,默认 5\n" - " --config confs 拉取的配置名,默认 dnfs.ini\n" - " --no-confs 跳过 confs 拉取,完全依赖 CLI + ENV + 默认值\n" " --log-level debug|info|warn|error,默认 info\n" " --instance 实例名(注册中心用),默认 dnfs2\n" " --help, -h 显示此帮助并退出\n\n" - "配置优先级: CLI > 环境变量 > confs > 默认值\n" - "使用 --no-confs 时跳过 confs,优先级变为: CLI > 环境变量 > 默认值\n"); + "配置优先级: CLI > 环境变量 > 默认值\n"); std::exit(0); } - if (arg == "--config" && i + 1 < argc) cfg.confs_config_name = argv[++i]; - if (arg == "--no-confs") cfg.no_confs = true; } - // 1. confs 引导地址 - if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; - if (const char* p = std::getenv("CONFS_PORT")) { - cfg.confs_port = safe_stoi(p, "CONFS_PORT"); - if (cfg.confs_port < 1 || cfg.confs_port > 65535) { - std::fprintf(stderr, "[dnfs2] invalid CONFS_PORT: %d (must be 1-65535)\n", cfg.confs_port); - std::exit(1); - } - } - - // 2. 从 confs 拉取配置文件(retry_on_refused=true,等待 confs 就绪) - // --no-confs 模式下跳过,直接用 ENV + CLI + 默认值 - if (!cfg.no_confs) { - std::string ini_text; - try { - RpcClient confs_client(cfg.confs_host, cfg.confs_port, "dnfs2", - /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); - if (data.is_object() && data.as_object().contains("content")) { - ini_text = std::string(data.as_object().at("content").as_string()); - } - } catch (const std::exception& e) { - std::fprintf(stderr, "[dnfs2] 从 confs 获取 %s 失败: %s\n", cfg.confs_config_name.c_str(), e.what()); - std::exit(1); - } - - // 3. 解析 INI - IniMap ini = parse_ini(ini_text); - - // 校验必需配置项 - static const std::pair required_keys[] = { - {"server", "host"}, - {"server", "port"}, - {"database", "host"}, - {"database", "port"}, - {"database", "timeout"}, - {"ai_review", "url"}, - {"ai_review", "timeout"}, - {"gateway", "url"}, - {"gateway", "timeout"}, - }; - for (auto& [sec, key] : required_keys) { - auto si = ini.find(sec); - if (si == ini.end() || si->second.find(key) == si->second.end()) { - std::fprintf(stderr, "[dnfs2] 缺少必需配置: [%s] %s\n", sec, key); - std::exit(1); - } - } - - auto get = [&](const std::string& sec, const std::string& key, - const std::string& def = "") -> std::string { - auto si = ini.find(sec); - if (si == ini.end()) return def; - auto ki = si->second.find(key); - return (ki == si->second.end()) ? def : ki->second; - }; - - cfg.listen_host = get("server", "host", cfg.listen_host); - cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); - cfg.db_host = get("database", "host", cfg.db_host); - cfg.db_port = safe_stoi(get("database", "port", std::to_string(cfg.db_port)), "[database] port"); - cfg.db_timeout = std::stod(get("database", "timeout", std::to_string(cfg.db_timeout))); - cfg.review_url = get("ai_review", "url", cfg.review_url); - cfg.review_timeout = std::stod(get("ai_review", "timeout", std::to_string(cfg.review_timeout))); - cfg.gateway_url = get("gateway", "url", cfg.gateway_url); - cfg.gateway_timeout = std::stod(get("gateway", "timeout", std::to_string(cfg.gateway_timeout))); - } - - // 4. 环境变量覆盖(Docker 兼容) + // 1. 环境变量覆盖(Docker 兼容) if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); if (const char* v = std::getenv("DB_HOST")) cfg.db_host = v; @@ -284,7 +164,7 @@ static Config load_config(int argc, char* argv[]) { if (const char* v = std::getenv("REVIEW_URL")) cfg.review_url = v; if (const char* v = std::getenv("GATEWAY_URL")) cfg.gateway_url = v; - // 5. 命令行参数(最高优先级) + // 2. 命令行参数(最高优先级) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; @@ -301,7 +181,7 @@ static Config load_config(int argc, char* argv[]) { else if (arg == "--instance" && i + 1 < argc) cfg.instance = argv[++i]; } - // 6. 数值范围校验 + // 3. 数值范围校验 if (cfg.listen_port < 1 || cfg.listen_port > 65535) { std::fprintf(stderr, "[dnfs2] invalid listen_port: %d (must be 1-65535)\n", cfg.listen_port); std::exit(1); @@ -560,13 +440,7 @@ public: ); _log_queue->start(); - if (cfg.no_confs) { - std::printf("[dnfs2] 配置来源: CLI + ENV + 默认值(--no-confs 模式)\n"); - } else { - std::printf("[dnfs2] 配置来源: confs (%s @ %s:%d)\n", - _cfg.confs_config_name.c_str(), - _cfg.confs_host.c_str(), _cfg.confs_port); - } + std::printf("[dnfs2] 配置来源: CLI + ENV + 默认值\n"); std::printf("[dnfs2] instance=%s log-level=%s\n", _cfg.instance.c_str(), _cfg.log_level.c_str()); std::printf("[dnfs2] Token 验签: gateway %s\n", _cfg.gateway_url.c_str()); From 01c6d0cb1f77c011011e4ac6ac8750db803c8b29 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 11:38:08 +0800 Subject: [PATCH 024/196] =?UTF-8?q?waf:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=EF=BC=88--log-level/--instance=20+=20config.yaml=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=85=A8=20CLI=20=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --listen/--host (覆盖 listen)、--upstream、--tls-cert、--tls-key、--rules-dir - 新增 --log-level debug|info|warn|error(logclient 加级别过滤) - 新增 --instance 实例名(注册中心用) - 自定义 --help 用法文本 - logclient: 新增 SetLevel() + Debug() + ENV LOG_LEVEL 支持 - services.yaml: waf 声明 --instance 和 --log-level --- core/services.yaml | 2 +- waf/internal/logclient/logclient.go | 44 ++++++++++++++++++++ waf/main.go | 63 ++++++++++++++++++++++++++--- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 2dedd24..3177a6a 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -71,7 +71,7 @@ services: # --- 安全链路 --- - name: waf cmd: bash /opt/micro_servers/waf/_run.sh - args: [] # -c config.yaml 保留在 _run.sh 作为默认值 + args: ["--instance", "waf", "--log-level", "info"] depends_on: [ca, gateway] sleep_before: 0 diff --git a/waf/internal/logclient/logclient.go b/waf/internal/logclient/logclient.go index 8523b60..554bb46 100644 --- a/waf/internal/logclient/logclient.go +++ b/waf/internal/logclient/logclient.go @@ -30,13 +30,47 @@ const ( maxMsgSize = 64 * 1024 * 1024 ) +// 日志级别数值(越小越详细)。 +const ( + levelDebug = 0 + levelInfo = 1 + levelWarn = 2 + levelError = 3 +) + +var levelMap = map[string]int{ + "debug": levelDebug, + "info": levelInfo, + "warn": levelWarn, + "warning": levelWarn, + "error": levelError, + "DEBUG": levelDebug, + "INFO": levelInfo, + "WARN": levelWarn, + "WARNING": levelWarn, + "ERROR": levelError, +} + // dbAddr 通过环境变量 LOG_DB_ADDR 配置(默认 127.0.0.1:8005),支持非标准部署场景。 var dbAddr = defaultDBAddr +// minLevel 控制最低输出级别,默认 info(debug 被过滤)。 +var minLevel = levelInfo + func init() { if v := os.Getenv("LOG_DB_ADDR"); v != "" { dbAddr = v } + if v := os.Getenv("LOG_LEVEL"); v != "" { + SetLevel(v) + } +} + +// SetLevel 设置最低日志级别(debug|info|warn|error,大小写不敏感)。 +func SetLevel(level string) { + if n, ok := levelMap[level]; ok { + minLevel = n + } } // Entry 单条日志。 @@ -93,10 +127,15 @@ func New() *Client { } // Push 推送一条日志(非阻塞,队列满时丢弃)。 +// 低于 SetLevel 设定的最低级别的日志将被静默丢弃。 func Push(service, level, message string, extra map[string]any) { if global == nil { return } + n, ok := levelMap[level] + if !ok || n < minLevel { + return + } global.push(Entry{ Service: service, Level: level, @@ -105,6 +144,11 @@ func Push(service, level, message string, extra map[string]any) { }) } +// Debug 推送 DEBUG 级别日志。 +func Debug(service, message string, extra map[string]any) { + Push(service, "DEBUG", message, extra) +} + // Info 推送 INFO 级别日志。 func Info(service, message string, extra map[string]any) { Push(service, "INFO", message, extra) diff --git a/waf/main.go b/waf/main.go index d5f95c9..a72cf16 100644 --- a/waf/main.go +++ b/waf/main.go @@ -42,15 +42,66 @@ type Config struct { func main() { var ( - configFile = flag.String("c", "config.yaml", "配置文件路径") - doList = flag.Bool("list", false, "列出所有规则后退出") - doTest = flag.String("test", "", "校验单个规则文件语法后退出") - doCheck = flag.String("check", "", "用当前规则测试输入字符串(URI/path)后退出") + configFile = flag.String("c", "config.yaml", "配置文件路径") + doList = flag.Bool("list", false, "列出所有规则后退出") + doTest = flag.String("test", "", "校验单个规则文件语法后退出") + doCheck = flag.String("check", "", "用当前规则测试输入字符串(URI/path)后退出") + // CLI 标准化参数(覆盖 config.yaml) + cliListen = flag.String("listen", "", "监听地址(别名: --host),覆盖 config.yaml") + cliHost = flag.String("host", "", "监听地址(--listen 别名)") + cliUpstream = flag.String("upstream", "", "gateway 地址,覆盖 config.yaml") + cliTLSCert = flag.String("tls-cert", "", "TLS 证书文件,覆盖 config.yaml") + cliTLSKey = flag.String("tls-key", "", "TLS 私钥文件,覆盖 config.yaml") + cliRulesDir = flag.String("rules-dir", "", "规则根目录,覆盖 config.yaml") + logLevel = flag.String("log-level", "info", "日志级别: debug|info|warn|error") + instance = flag.String("instance", "waf", "实例名(注册中心用)") ) + flag.Usage = func() { + fmt.Fprintf(os.Stderr, + "waf — WAF 入口网关\n\n"+ + "用法: waf [选项]\n\n"+ + "选项:\n"+ + " -c 配置文件路径,默认 config.yaml\n"+ + " --listen 监听地址(别名: --host),覆盖 config.yaml\n"+ + " --upstream gateway 地址,覆盖 config.yaml\n"+ + " --tls-cert TLS 证书文件,覆盖 config.yaml\n"+ + " --tls-key TLS 私钥文件,覆盖 config.yaml\n"+ + " --rules-dir 规则根目录,覆盖 config.yaml\n"+ + " --log-level debug|info|warn|error,默认 info\n"+ + " --instance 实例名,默认 waf\n"+ + " -list 列出所有规则后退出\n"+ + " -test 校验单个规则文件语法后退出\n"+ + " -check 用当前规则测试输入字符串后退出\n"+ + " -h, --help 显示此帮助并退出\n\n"+ + "配置优先级: CLI > config.yaml\n") + } flag.Parse() + // 日志级别 + logclient.SetLevel(*logLevel) + cfg := loadConfig(*configFile) + // CLI 覆盖 config.yaml + if *cliListen != "" { + cfg.Listen = *cliListen + } + if *cliHost != "" { + cfg.Listen = *cliHost + } + if *cliUpstream != "" { + cfg.Upstream = *cliUpstream + } + if *cliTLSCert != "" { + cfg.TLSCert = *cliTLSCert + } + if *cliTLSKey != "" { + cfg.TLSKey = *cliTLSKey + } + if *cliRulesDir != "" { + cfg.RulesDir = *cliRulesDir + } + // 子命令:极客 CLI 工具 if *doTest != "" { if err := engine.TestFile(*doTest); err != nil { @@ -146,8 +197,8 @@ func main() { termCh := make(chan os.Signal, 1) signal.Notify(termCh, syscall.SIGINT, syscall.SIGTERM) - logclient.Info("waf", fmt.Sprintf("服务启动 listen=%s upstream=%s rules=%s pid=%d", - cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil) + logclient.Info("waf", fmt.Sprintf("服务启动 instance=%s listen=%s upstream=%s rules=%s pid=%d", + *instance, cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil) errCh := make(chan error, 1) go func() { From 4275f002e0b737a621e92bef72f6a55e49cf62a4 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 11:45:27 +0800 Subject: [PATCH 025/196] =?UTF-8?q?redirect80:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=EF=BC=88argparse=20+=20log=5Fclient=20=E7=BA=A7?= =?UTF-8?q?=E5=88=AB=E8=BF=87=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host/--port/--redirect-port/--redirect-code CLI 参数(覆盖 confs+ENV) - 新增 --log-level debug|info|warn|error(log_client 加级别过滤) - 新增 --instance 实例名(注册中心用) - 自定义 --help 用法文本(argparse,confs 不可用时也能 --help) - log_client: 新增 set_log_level() + push() 级别过滤 - 修复: redirect_code 配置项原加载但未使用,do_GET/do_HEAD 改为用 _config['redirect_code'] - 配置优先级: CLI > ENV > confs > 默认值 - services.yaml: redirect80 声明 --instance 和 --log-level --- core/services.yaml | 2 +- redirect80/log_client.py | 21 +++++ redirect80/redirect80.py | 180 ++++++++++++++++++++++++++++----------- 3 files changed, 150 insertions(+), 53 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 3177a6a..905b6e6 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -104,6 +104,6 @@ services: # --- 端口重定向(最后启动) --- - name: redirect80 cmd: bash /opt/micro_servers/redirect80/_run.sh - args: [] + args: ["--instance", "redirect80", "--log-level", "info"] depends_on: [waf] sleep_before: 0 diff --git a/redirect80/log_client.py b/redirect80/log_client.py index 8529be7..dc3e905 100644 --- a/redirect80/log_client.py +++ b/redirect80/log_client.py @@ -8,6 +8,7 @@ - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 + - 日志级别过滤:set_level() 设定阈值,低于阈值的日志直接丢弃 用法(与旧版完全兼容): from log_client import logs @@ -16,6 +17,10 @@ logs.error("redirect80", "端口绑定失败", extra={"errno": 98}) logs.push("redirect80", "WARNING", "confs 不可用,使用本地配置", trace_id="startup", extra={"fallback": "redirect80.ini"}) + + # 设置日志级别(main 启动时调用) + from log_client import set_log_level + set_log_level("warn") # 只输出 warn/error """ import json @@ -37,6 +42,19 @@ _FLUSH_INTERVAL = 2.0 _TIMEOUT = 5 _QUEUE_MAXSIZE = 10000 +# ── 日志级别过滤 ──────────────────────────────────────────────────── +_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "WARN": 2, "ERROR": 3, "CRITICAL": 3} +_level_threshold = 1 # 默认 INFO + + +def set_log_level(level: str): + """设置全局日志级别阈值。低于此级别的日志直接丢弃。""" + global _level_threshold + lv = level.upper() + if lv == "WARNING": + lv = "WARN" + _level_threshold = _LEVEL_ORDER.get(lv, 1) + def _safe_json(obj): """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" @@ -79,8 +97,11 @@ class LogClient: trace_id: str = None, extra=None): """推送一条日志(非阻塞,立即返回)。 + 低于设定级别的日志直接丢弃。 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 """ + if _LEVEL_ORDER.get(level.upper(), 1) < _level_threshold: + return entry = { 'ts': _now_iso(), 'service': service, diff --git a/redirect80/redirect80.py b/redirect80/redirect80.py index c231417..0d2465e 100644 --- a/redirect80/redirect80.py +++ b/redirect80/redirect80.py @@ -7,12 +7,14 @@ redirect80 — HTTP 80 端口重定向服务。 技术选型:Python 内置 http.server + 多线程混合模式,无外部 Web 框架依赖。 日志通过 log_client 异步推送到 db 的 Logs 表(fail-safe)。 -配置(两层加载): +配置(三层加载): 1. confs 强依赖拉取 redirect80.ini(retry_on_refused 阻塞等待) 2. 环境变量覆盖(Docker/k8s 兼容) -confs 不可用时直接退出,不做本地回退。 + 3. 命令行参数(最高优先级) +配置不可用时直接退出,不做本地回退。 """ +import argparse import configparser import os import sys as _sys @@ -22,57 +24,124 @@ from socketserver import ThreadingMixIn # ── 路径设置 ────────────────────────────────────────────────────────── _here = os.path.dirname(os.path.abspath(__file__)) _sys.path.insert(0, os.path.join(_here, "..")) -from log_client import logs # noqa: E402 +from log_client import logs, set_log_level # noqa: E402 -# ── 配置加载(confs 强依赖 → 环境变量覆盖)──────────────────────── -_config = { - "host": "0.0.0.0", - "port": 80, - "redirect_port": 443, - "redirect_code": 301, -} - -# 1. confs 强依赖拉取配置(阻塞等待 confs 就绪,失败则退出) _sys.path.insert(0, os.path.join(_here, "..", "pymsg")) from pymsg import RpcClient # noqa: E402 -_confs_host = os.environ.get("CONFS_HOST", "127.0.0.1") -_confs_port = int(os.environ.get("CONFS_PORT", "8010")) -_confs_client = RpcClient( - _confs_host, _confs_port, "redirect80", - retry_on_refused=True, timeout=5, -) -_data = _confs_client.call("get", {"name": "redirect80.ini"}) -_ini_text = _data.get("content", "") -_ini = configparser.ConfigParser(interpolation=None) -_ini.read_string(_ini_text) -_merge_map = { - ("server", "host"): "host", - ("server", "port"): "port", - ("server", "redirect_port"): "redirect_port", - ("server", "redirect_code"): "redirect_code", -} -for (sec, key), var in _merge_map.items(): - if _ini.has_option(sec, key): - val = _ini.get(sec, key) - _config[var] = type(_config[var])(val) -logs.info("redirect80", "配置已从 confs 加载") +# ── 配置加载 ───────────────────────────────────────────────────────── + +def load_config(argv): + """三层加载: confs → ENV → CLI(最高优先级)。""" + + # argparse 先行:--help 在任何网络调用前处理 + parser = argparse.ArgumentParser( + prog="redirect80", + description="HTTP 80 端口重定向服务", + add_help=False, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="配置优先级: CLI > 环境变量 > confs > 默认值", + ) + parser.add_argument("--host", default=None, + help="绑定地址,默认 0.0.0.0") + parser.add_argument("--port", type=int, default=None, + help="监听端口,默认 80") + parser.add_argument("--redirect-port", type=int, default=None, + help="重定向目标端口,默认 443") + parser.add_argument("--redirect-code", type=int, default=None, + help="GET/HEAD 重定向状态码,默认 301") + parser.add_argument("--log-level", default=None, + help="debug|info|warn|error,默认 info") + parser.add_argument("--instance", default=None, + help="实例名(注册中心用),默认 redirect80") + parser.add_argument("--help", "-h", action="help", + help="显示此帮助并退出") + args = parser.parse_args(argv) + + # 默认值 + config = { + "host": "0.0.0.0", + "port": 80, + "redirect_port": 443, + "redirect_code": 301, + "log_level": "info", + "instance": "redirect80", + } + + # 1. confs 强依赖拉取配置(阻塞等待 confs 就绪,失败则退出) + confs_host = os.environ.get("CONFS_HOST", "127.0.0.1") + confs_port = int(os.environ.get("CONFS_PORT", "8010")) + try: + client = RpcClient( + confs_host, confs_port, "redirect80", + retry_on_refused=True, timeout=5, + ) + data = client.call("get", {"name": "redirect80.ini"}) + ini_text = data.get("content", "") + ini = configparser.ConfigParser(interpolation=None) + ini.read_string(ini_text) + + _merge = { + ("server", "host"): "host", + ("server", "port"): "port", + ("server", "redirect_port"): "redirect_port", + ("server", "redirect_code"): "redirect_code", + } + for (sec, key), var in _merge.items(): + if ini.has_option(sec, key): + val = ini.get(sec, key) + config[var] = type(config[var])(val) + logs.info("redirect80", "配置已从 confs 加载") + except Exception as e: + print(f"[redirect80] confs 获取失败: {e}", file=_sys.stderr) + _sys.exit(1) + + # 2. 环境变量覆盖 + _env_map = { + "REDIRECT80_HOST": "host", + "REDIRECT80_PORT": ("port", int), + "REDIRECT80_REDIRECT_PORT": ("redirect_port", int), + "REDIRECT80_REDIRECT_CODE": ("redirect_code", int), + } + for env_var, mapping in _env_map.items(): + val = os.environ.get(env_var) + if val is not None: + if isinstance(mapping, tuple): + config[mapping[0]] = mapping[1](val) + else: + config[mapping] = val + + # 3. CLI 参数(最高优先级) + if args.host is not None: + config["host"] = args.host + if args.port is not None: + config["port"] = args.port + if args.redirect_port is not None: + config["redirect_port"] = args.redirect_port + if args.redirect_code is not None: + config["redirect_code"] = args.redirect_code + if args.log_level is not None: + config["log_level"] = args.log_level + if args.instance is not None: + config["instance"] = args.instance + + # log_level 合法性校验 + lv = config["log_level"].lower() + if lv == "warning": + lv = "warn" + if lv not in ("debug", "info", "warn", "error"): + print(f"[redirect80] invalid log_level: {config['log_level']} " + f"(must be debug|info|warn|error)", file=_sys.stderr) + _sys.exit(1) + config["log_level"] = lv + + return config + + +# ── 模块级配置(main() 中赋值)────────────────────────────────────── +_config = {} -# 2. 环境变量最高优先级覆盖 -_env_map = { - "REDIRECT80_HOST": "host", - "REDIRECT80_PORT": ("port", int), - "REDIRECT80_REDIRECT_PORT": ("redirect_port", int), - "REDIRECT80_REDIRECT_CODE": ("redirect_code", int), -} -for env_var, mapping in _env_map.items(): - val = os.environ.get(env_var) - if val is not None: - if isinstance(mapping, tuple): - _config[mapping[0]] = mapping[1](val) - else: - _config[mapping] = val # ── 请求处理器 ────────────────────────────────────────────────────────── @@ -110,10 +179,10 @@ class RedirectHandler(BaseHTTPRequestHandler): f"{code} {self.command} {host} → {location}") def do_GET(self): - self._redirect(301) + self._redirect(_config["redirect_code"]) def do_HEAD(self): - self._redirect(301) + self._redirect(_config["redirect_code"]) def do_POST(self): self._redirect(308) @@ -158,17 +227,24 @@ class ThreadingRedirectServer(ThreadingMixIn, HTTPServer): def main(): + global _config + _config = load_config(_sys.argv[1:]) + + # 设置日志级别过滤 + set_log_level(_config["log_level"]) + host = _config["host"] port = int(_config["port"]) server = ThreadingRedirectServer((host, port), RedirectHandler) logs.info("redirect80", - f"HTTP→HTTPS 重定向服务启动 " + f"服务启动 instance={_config['instance']} " f"http://{host}:{port} → https://*:{_config['redirect_port']}") - print(f"[redirect80] 监听 http://{host}:{port} " - f"→ 重定向到 :{_config['redirect_port']}", file=_sys.stderr) + print(f"[redirect80] instance={_config['instance']} log-level={_config['log_level']} " + f"监听 http://{host}:{port} → 重定向到 :{_config['redirect_port']}", + file=_sys.stderr) try: server.serve_forever() From 4df510712eb53be119fd3762f19782ac6c4fb586 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 11:51:43 +0800 Subject: [PATCH 026/196] =?UTF-8?q?ca:=20CLI=20=E6=A0=87=E5=87=86=E5=8C=96?= =?UTF-8?q?=EF=BC=88--host/--port/--log-level/--instance=20+=20LogC=20?= =?UTF-8?q?=E7=BA=A7=E5=88=AB=E8=BF=87=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host/--port 覆盖 [Server] address/port - 新增 --config 覆盖 confs 拉取的配置名(默认 ca.ini) - 新增 --log-level debug|info|warn|error(LogC 加级别过滤) - 新增 --instance 实例名(注册中心用) - LogC.hpp: 新增 set_level() + push() 级别过滤(atomic 阈值) - 配置优先级: CLI > ENV > confs > 默认值 - services.yaml: ca 声明 --instance 和 --log-level --- ca/inc/LogC.hpp | 29 +++++++++++++++++++++++++++ ca/src/main.cpp | 49 +++++++++++++++++++++++++++++++++++++++++----- core/services.yaml | 2 +- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/ca/inc/LogC.hpp b/ca/inc/LogC.hpp index e58736e..021411f 100644 --- a/ca/inc/LogC.hpp +++ b/ca/inc/LogC.hpp @@ -18,6 +18,9 @@ * * // 进程退出前: * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + * + * // 设置日志级别(main 启动时调用,低于此级别的日志直接丢弃) + * qing::LogC::instance().set_level("warn"); // 只输出 warn/error */ #include @@ -27,6 +30,7 @@ #include #include #include +#include #include #include @@ -48,6 +52,17 @@ public: std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) }; + // 日志级别 → 数值(大写兼容) + static const std::unordered_map& level_order() { + static const std::unordered_map k = { + {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, + {"error", 3}, {"critical", 3}, + {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, + {"ERROR", 3}, {"CRITICAL", 3}, + }; + return k; + } + static LogC& instance() { static LogC inst; return inst; @@ -55,11 +70,24 @@ public: // ---- 公开接口 -------------------------------------------------------- + // 设置日志级别阈值(debug|info|warn|error,大小写不限) + // 低于此级别的日志在 push 时直接丢弃 + void set_level(const std::string& level) { + auto it = level_order().find(level); + if (it != level_order().end()) { + _level_threshold.store(it->second, std::memory_order_relaxed); + } + } + // 推送一条日志(非阻塞,立即返回) void push(const std::string& service, const std::string& level, const std::string& message, std::optional trace_id = std::nullopt, std::optional extra = std::nullopt) { + // 日志级别过滤:低于阈值直接丢弃 + auto it = level_order().find(level); + int lvl = (it != level_order().end()) ? it->second : 1; + if (lvl < _level_threshold.load(std::memory_order_relaxed)) return; _queue.push(Entry{service, level, message, std::move(trace_id), std::move(extra)}); } @@ -193,6 +221,7 @@ private: std::string _db_addr = "127.0.0.1"; int _db_port = 8005; std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) + std::atomic _level_threshold{1}; // 日志级别阈值,默认 INFO(1) static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush diff --git a/ca/src/main.cpp b/ca/src/main.cpp index 78b7a44..d6e193c 100644 --- a/ca/src/main.cpp +++ b/ca/src/main.cpp @@ -406,6 +406,9 @@ static std::unordered_map _WS_PROXY_ROUTES; /* instance items */ static std::unique_ptr _SERVE; static std::unique_ptr _KEYGEN; +/* CLI 标准化参数 */ +static std::string _INSTANCE = "ca"; // --instance,注册中心用 +static std::string _LOG_LEVEL = "info"; // --log-level,debug|info|warn|error //static std::unique_ptr _SQLDB; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; @@ -769,6 +772,11 @@ int main(int argc, char **argv) { desc.add_options() ("help,h", "output help message") ("version,v", "output version message") + ("host", po::value(), "绑定地址(默认 127.0.0.1),覆盖 [Server] address") + ("port", po::value(), "监听端口(默认 8443),覆盖 [Server] port") + ("config", po::value(), "confs 拉取的配置名(默认 ca.ini)") + ("log-level", po::value(), "日志级别 debug|info|warn|error(默认 info)") + ("instance", po::value(), "实例名(注册中心用,默认 ca)") ; @@ -815,15 +823,21 @@ int main(int argc, char **argv) { _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + /* --config 覆盖 confs 拉取的配置名(在 confs 拉取前读取) */ + std::string confs_config_name = "ca.ini"; + if (vm.count("config")) { + confs_config_name = vm["config"].as(); + } + ConfIni cf; try { - /* 从 confs 拉取 ca.ini */ + /* 从 confs 拉取配置 */ std::cout << "[ca] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT - << ") 拉取 ca.ini ..." << std::endl; + << ") 拉取 " << confs_config_name << " ..." << std::endl; pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "ca"); json::object req; req["_route"] = "get"; - req["name"] = "ca.ini"; + req["name"] = confs_config_name; std::string resp_str = confs_client.call( json::serialize(req), /*timeout=*/5.0, /*retry_on_refused=*/true); @@ -917,7 +931,28 @@ int main(int argc, char **argv) { int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; - /* 4. 配置完整性校验 */ + /* 4. CLI 参数覆盖(最高优先级) */ + if (vm.count("host")) _ADDR = vm["host"].as(); + if (vm.count("port")) _PORT = vm["port"].as(); + if (vm.count("instance")) _INSTANCE = vm["instance"].as(); + if (vm.count("log-level")) { + _LOG_LEVEL = vm["log-level"].as(); + // 归一化 warning → warn + if (_LOG_LEVEL == "warning") _LOG_LEVEL = "warn"; + // 合法性校验 + static const char* valid[] = {"debug", "info", "warn", "error"}; + bool ok = false; + for (const char* v : valid) { + if (_LOG_LEVEL == v) { ok = true; break; } + } + if (!ok) { + std::cerr << "[ca] 致命错误: 非法 --log-level: " << _LOG_LEVEL + << "(合法值: debug|info|warn|error)" << std::endl; + return 1; + } + } + + /* 5. 配置完整性校验 */ if (_ADDR.empty() || _PORT == -1) { std::cerr << "[ca] 致命错误: 配置项 [Server] address/port 缺失" "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; @@ -937,8 +972,12 @@ int main(int argc, char **argv) { DbC::init(_DB_HOST, _DB_PORT); logs().set_endpoint(_DB_HOST, _DB_PORT); + /* 设置日志级别过滤 */ + logs().set_level(_LOG_LEVEL); + std::cout << "[ca] 监听: " << _ADDR << ":" << _PORT << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[ca] 实例: " << _INSTANCE << " 日志级别: " << _LOG_LEVEL << std::endl; std::cout << "[ca] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; @@ -984,7 +1023,7 @@ int main(int argc, char **argv) { // 等待服务器启动完成(简单延迟,确保started日志打印后再输出提示) std::this_thread::sleep_for(std::chrono::milliseconds(200)); std::cout << "CA " << _VER << " started" << std::endl; - logs().info("ca", std::string("CA ") + _VER + " started"); + logs().info("ca", std::string("CA ") + _VER + " started (instance=" + _INSTANCE + ")"); /* 主线程:等待关闭信号,带超时轮询 */ while (!_stop_requested && !server_exited) { diff --git a/core/services.yaml b/core/services.yaml index 905b6e6..1be432e 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -77,7 +77,7 @@ services: - name: ca cmd: bash /opt/micro_servers/ca/_run.sh - args: [] + args: ["--instance", "ca", "--log-level", "info"] depends_on: [db2] sleep_before: 2 From 8eb14cad4a36caa66aded0927ece8ef5e7bb5437 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 11:55:54 +0800 Subject: [PATCH 027/196] =?UTF-8?q?gateway:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=EF=BC=88--host/--port/--config/--log-level/--instance?= =?UTF-8?q?=20+=20LogC=20=E7=BA=A7=E5=88=AB=E8=BF=87=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host/--port 覆盖 [Server] address/port - 新增 --config 覆盖 confs 拉取的配置名(默认 gateway.ini) - 新增 --log-level debug|info|warn|error(LogC 加级别过滤) - 新增 --instance 实例名(注册中心用) - LogC.hpp: 新增 set_level() + push() 级别过滤(atomic 阈值,大写兼容) - 配置优先级: CLI > ENV > confs > 默认值 - services.yaml: gateway 声明 --instance 和 --log-level --- core/services.yaml | 2 +- gateway/inc/LogC.hpp | 29 +++++++++++++++++++++++++++++ gateway/src/main.cpp | 31 +++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 1be432e..735379c 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -83,7 +83,7 @@ services: - name: gateway cmd: bash /opt/micro_servers/gateway/_run.sh - args: [] + args: ["--instance", "gateway", "--log-level", "info"] depends_on: [db2] sleep_before: 2 diff --git a/gateway/inc/LogC.hpp b/gateway/inc/LogC.hpp index aef0923..a77d23d 100644 --- a/gateway/inc/LogC.hpp +++ b/gateway/inc/LogC.hpp @@ -18,6 +18,9 @@ * * // 进程退出前: * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + * + * // 设置日志级别(main 启动时调用,低于此级别的日志直接丢弃) + * qing::LogC::instance().set_level("warn"); // 只输出 warn/error */ #include @@ -27,6 +30,7 @@ #include #include #include +#include #include #include @@ -55,11 +59,35 @@ public: // ---- 公开接口 -------------------------------------------------------- + // 日志级别 → 数值(大写兼容) + static const std::unordered_map& level_order() { + static const std::unordered_map k = { + {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, + {"error", 3}, {"critical", 3}, + {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, + {"ERROR", 3}, {"CRITICAL", 3}, + }; + return k; + } + + // 设置日志级别阈值(debug|info|warn|error,大小写不限) + // 低于此级别的日志在 push 时直接丢弃 + void set_level(const std::string& level) { + auto it = level_order().find(level); + if (it != level_order().end()) { + _level_threshold.store(it->second, std::memory_order_relaxed); + } + } + // 推送一条日志(非阻塞,立即返回) void push(const std::string& service, const std::string& level, const std::string& message, std::optional trace_id = std::nullopt, std::optional extra = std::nullopt) { + // 日志级别过滤:低于阈值直接丢弃 + auto it = level_order().find(level); + int lvl = (it != level_order().end()) ? it->second : 1; + if (lvl < _level_threshold.load(std::memory_order_relaxed)) return; _queue.push(Entry{service, level, message, std::move(trace_id), std::move(extra)}); } @@ -193,6 +221,7 @@ private: std::string _db_addr = "127.0.0.1"; int _db_port = 8005; std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) + std::atomic _level_threshold{1}; // 日志级别阈值,默认 INFO(1) static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 789a3b9..3fd2bc9 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -109,6 +109,11 @@ static int _DB_PORT = 8005; /* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ static std::string _CONFS_HOST = "127.0.0.1"; static int _CONFS_PORT = 8010; +/* confs 配置名(--config 覆盖,默认 gateway.ini) */ +static std::string _CONFS_CONFIG_NAME = "gateway.ini"; +/* CLI 标准化参数(注册中心 / 日志级别) */ +static std::string _INSTANCE = "gateway"; +static std::string _LOG_LEVEL = "info"; /* instance items */ static std::unique_ptr _SERVE; @@ -164,6 +169,11 @@ int main(int argc, char **argv) { desc.add_options() ("help,h", "output help message") ("version,v", "output version message") + ("host", po::value(), "绑定地址(覆盖 [Server] address,默认 127.0.0.1)") + ("port", po::value(), "监听端口(覆盖 [Server] port,默认 8445)") + ("config", po::value(), "confs 拉取的配置名(默认 gateway.ini)") + ("log-level", po::value(), "日志级别 debug|info|warn|error,默认 info") + ("instance", po::value(), "实例名(注册中心用,默认 gateway)") ; @@ -195,6 +205,10 @@ int main(int argc, char **argv) { return 0; } + /* CLI 预扫描:--config 必须在 confs 拉取前处理(其余 CLI 覆盖在 ENV 之后接入) */ + if (vm.count("config")) + _CONFS_CONFIG_NAME = vm["config"].as(); + /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== * 与 Python 服务(dnfs/db/static)保持一致的加载模式: * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 @@ -214,11 +228,11 @@ int main(int argc, char **argv) { try { /* 从 confs 拉取 gateway.ini */ std::cout << "[gateway] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT - << ") 拉取 gateway.ini ..." << std::endl; + << ") 拉取 " << _CONFS_CONFIG_NAME << " ..." << std::endl; pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "gateway"); json::object req; req["_route"] = "get"; - req["name"] = "gateway.ini"; + req["name"] = _CONFS_CONFIG_NAME; std::string resp_str = confs_client.call( json::serialize(req), /*timeout=*/5.0, /*retry_on_refused=*/true); @@ -268,6 +282,16 @@ int main(int argc, char **argv) { int env_db_port = ConfIni::env_int("DB_PORT", -1); if (env_db_port > 0) _DB_PORT = env_db_port; + /* 3b. CLI 覆盖(最高优先级):--host / --port / --instance / --log-level */ + if (vm.count("host")) + _ADDR = vm["host"].as(); + if (vm.count("port")) + _PORT = vm["port"].as(); + if (vm.count("instance")) + _INSTANCE = vm["instance"].as(); + if (vm.count("log-level")) + _LOG_LEVEL = vm["log-level"].as(); + /* 4. 配置完整性校验 */ if (_ADDR.empty() || _PORT == -1) { std::cerr << "[gateway] 致命错误: 配置项 [Server] address/port 缺失" @@ -295,9 +319,12 @@ int main(int argc, char **argv) { /* 初始化日志推送端点 */ logs().set_endpoint(_DB_HOST, _DB_PORT); + logs().set_level(_LOG_LEVEL); std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[gateway] instance=" << _INSTANCE + << " log-level=" << _LOG_LEVEL << std::endl; // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 // HTTP 模式用于 ca 前置后的内部转发链路 From 29eac70f24bcb8ca347b7a40ddb5b26d390808e7 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 12:00:34 +0800 Subject: [PATCH 028/196] =?UTF-8?q?captcha2:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=EF=BC=88--host/--config/--log-level/--instance=20+=20?= =?UTF-8?q?LogC=20=E7=BA=A7=E5=88=AB=E8=BF=87=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host(别名 --addr/-a)覆盖 [server] host - 新增 --config 覆盖 confs 拉取的配置名(默认 captcha.ini) - 新增 --log-level debug|info|warn|error(LogC 加级别过滤) - 新增 --instance 实例名(注册中心用) - LogC.hpp: 新增 set_level() + push() 级别过滤(atomic 阈值,大写兼容) - 配置优先级: CLI > ENV > confs > 默认值 - services.yaml: captcha2 声明 --instance 和 --log-level --- captcha2/inc/LogC.hpp | 28 ++++++++++++++++++++++++++++ captcha2/src/main.cpp | 32 +++++++++++++++++++++++++------- core/services.yaml | 2 +- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/captcha2/inc/LogC.hpp b/captcha2/inc/LogC.hpp index 0b21a1b..67e201f 100644 --- a/captcha2/inc/LogC.hpp +++ b/captcha2/inc/LogC.hpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include namespace captcha2 { @@ -30,11 +32,31 @@ struct LogEntry { class LogC { public: + // 日志级别 → 数值(大写兼容) + static const std::unordered_map& level_order() { + static const std::unordered_map k = { + {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, + {"error", 3}, {"critical", 3}, + {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, + {"ERROR", 3}, {"CRITICAL", 3}, + }; + return k; + } + static LogC& instance() { static LogC inst; return inst; } + // 设置日志级别阈值(debug|info|warn|error,大小写不限) + // 低于此级别的日志在 push 时直接丢弃 + void set_level(const std::string& level) { + auto it = level_order().find(level); + if (it != level_order().end()) { + level_threshold_.store(it->second, std::memory_order_relaxed); + } + } + void info(const std::string& svc, const std::string& msg) { push(svc, "INFO", msg); } @@ -64,6 +86,11 @@ private: ~LogC() { if (queue_) queue_->shutdown(); } void push(const std::string& svc, const std::string& level, const std::string& msg) { + // 日志级别过滤:低于阈值直接丢弃 + auto it = level_order().find(level); + int lvl = (it != level_order().end()) ? it->second : 1; + if (lvl < level_threshold_.load(std::memory_order_relaxed)) return; + auto now = std::chrono::system_clock::now(); auto t = std::chrono::system_clock::to_time_t(now); char buf[32]; @@ -105,6 +132,7 @@ private: std::string db_host_; int db_port_; + std::atomic level_threshold_{1}; // 日志级别阈值,默认 INFO(1) std::unique_ptr> queue_; }; diff --git a/captcha2/src/main.cpp b/captcha2/src/main.cpp index 3c8737b..b54706e 100644 --- a/captcha2/src/main.cpp +++ b/captcha2/src/main.cpp @@ -70,12 +70,13 @@ struct CaptchaConfig { int rate_limit_per_min = 600; // P1-1: 全局限流仅作最后防线,主要限流由 gateway 按 IP 负责 }; -// 从 confs 拉取 captcha.ini 全文,阻塞等待 confs 就绪 -static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port) { +// 从 confs 拉取指定名称的配置文件全文,阻塞等待 confs 就绪 +static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port, + const std::string& config_name) { pmc::net::RpcClient client(confs_host, confs_port, "captcha2", /*timeout=*/5.0, /*retry_on_refused=*/true); try { - auto data = client.call("get", {{"name", "captcha.ini"}}); + auto data = client.call("get", {{"name", config_name}}); auto* content = data.as_object().if_contains("content"); if (!content || !content->is_string()) { std::cerr << "[captcha2] confs 返回的 captcha.ini 无 content 字段" << std::endl; @@ -531,6 +532,9 @@ int main(int argc, char* argv[]) { // ── 命令行参数 ── std::string confs_host = "127.0.0.1"; int confs_port = 8010; + std::string confs_config_name = "captcha.ini"; // --config 覆盖 + std::string instance = "captcha2"; // --instance 覆盖 + std::string log_level = "info"; // --log-level 覆盖 { const char* env; if ((env = std::getenv("CONFS_HOST"))) confs_host = env; @@ -540,9 +544,13 @@ int main(int argc, char* argv[]) { po::options_description desc("captcha2 — pymsg 验证码服务"); desc.add_options() ("help,h", "显示帮助") - ("addr,a", po::value(), "监听地址(覆盖 confs)") + ("addr,a", po::value(), "监听地址(覆盖 confs,别名 --host)") + ("host", po::value(), "监听地址(覆盖 confs,别名 --addr/-a)") ("port,p", po::value(), "监听端口(覆盖 confs)") - ("workers,w", po::value(), "工作线程数(覆盖 confs)"); + ("workers,w", po::value(), "工作线程数(覆盖 confs)") + ("config", po::value(), "confs 拉取的配置名(默认 captcha.ini)") + ("log-level", po::value(), "日志级别 debug|info|warn|error,默认 info") + ("instance", po::value(), "实例名(注册中心用,默认 captcha2)"); po::variables_map vm; po::store(po::parse_command_line(argc, argv, desc), vm); po::notify(vm); @@ -552,20 +560,30 @@ int main(int argc, char* argv[]) { return 0; } + // 预扫描 --config / --instance / --log-level / --host(confs 拉取前处理) + if (vm.count("config")) confs_config_name = vm["config"].as(); + if (vm.count("instance")) instance = vm["instance"].as(); + if (vm.count("log-level")) log_level = vm["log-level"].as(); + // ── 配置加载:confs → env → 命令行 ── - std::string ini_text = fetch_config_from_confs(confs_host, confs_port); + std::string ini_text = fetch_config_from_confs(confs_host, confs_port, confs_config_name); merge_ini(g_config, ini_text); apply_env_overrides(g_config); if (vm.count("addr")) g_config.host = vm["addr"].as(); + if (vm.count("host")) g_config.host = vm["host"].as(); if (vm.count("port")) g_config.port = vm["port"].as(); if (vm.count("workers")) g_config.workers = vm["workers"].as(); validate_config(g_config); - std::cerr << "[captcha2] 配置来源: confs captcha.ini @ " + g_log.set_level(log_level); + + std::cerr << "[captcha2] 配置来源: confs " << confs_config_name << " @ " << confs_host << ":" << confs_port << std::endl; + std::cerr << "[captcha2] instance=" << instance + << " log-level=" << log_level << std::endl; std::cerr << "[captcha2] captcha_len=" << g_config.len << " ttl=" << g_config.ttl << " max_store=" << g_config.max_store diff --git a/core/services.yaml b/core/services.yaml index 735379c..2fcdfcb 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -90,7 +90,7 @@ services: # --- 验证码 --- - name: captcha2 cmd: bash /opt/micro_servers/captcha2/_run.sh - args: [] + args: ["--instance", "captcha2", "--log-level", "info"] depends_on: [] sleep_before: 1 From d0ee94833913caeba8f4c5e1fbdb0a2453acf6f2 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 12:02:47 +0800 Subject: [PATCH 029/196] =?UTF-8?q?ai=5Freview:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=EF=BC=88argparse=20+=20--host/--port/--log-level/--in?= =?UTF-8?q?stance=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --host/--port 覆盖 $AI_REVIEW_HOST/$AI_REVIEW_PORT(CLI > ENV > 默认值) - 新增 --log-level debug|info|warn|error(启动打印,ai_review 无 db 日志客户端故仅作标准接口) - 新增 --instance 实例名(注册中心用) - 配置加载从模块顶层移入 __main__,argparse 解析 - services.yaml: ai_review 声明 --instance 和 --log-level --- ai_review/ai_review.py | 30 ++++++++++++++++++++++++++++++ core/services.yaml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ai_review/ai_review.py b/ai_review/ai_review.py index dd24241..ac687d4 100644 --- a/ai_review/ai_review.py +++ b/ai_review/ai_review.py @@ -165,9 +165,39 @@ def rag_list(): return jsonify({"total": len(entries), "entries": entries}), 200 +def _parse_args(): + """解析命令行参数(标准化:CLI > ENV > 默认值)。""" + import argparse + p = argparse.ArgumentParser( + prog="ai_review", + description="AI Review Service — 基于 DeepSeek + RAG 的内容审核微服务", + ) + p.add_argument("--host", default=None, + help="绑定地址(默认读取 $AI_REVIEW_HOST,否则 127.0.0.1)") + p.add_argument("--port", type=int, default=None, + help="监听端口(默认读取 $AI_REVIEW_PORT,否则 8007)") + p.add_argument("--log-level", default="info", + choices=["debug", "info", "warn", "error"], + help="日志级别(默认 info)") + p.add_argument("--instance", default="ai_review", + help="实例名(注册中心用,默认 ai_review)") + return p.parse_args() + + +# ── 配置默认值(ENV 覆盖,CLI 最高优先级)─────────────────────────── ADDR = os.getenv("AI_REVIEW_HOST", "127.0.0.1") PORT = int(os.getenv("AI_REVIEW_PORT", "8007")) if __name__ == "__main__": + args = _parse_args() + + # CLI 覆盖(最高优先级) + if args.host is not None: + ADDR = args.host + if args.port is not None: + PORT = args.port + print(f'AI Review Service listening at http://{ADDR}:{PORT}') + print(f'[ai_review] instance={args.instance} log-level={args.log_level}') + print(f'[ai_review] 配置来源: CLI > ENV > 默认值') serve(app, host=ADDR, port=PORT) diff --git a/core/services.yaml b/core/services.yaml index 2fcdfcb..763021c 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -21,7 +21,7 @@ services: # --- AI 审核 --- - name: ai_review cmd: bash /opt/micro_servers/ai_review/_run.sh - args: [] + args: ["--instance", "ai_review", "--log-level", "info"] depends_on: [db2] sleep_before: 2 From 9fc3542771acf13f1c3d15a5e7fce02e94050593 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 12:05:46 +0800 Subject: [PATCH 030/196] =?UTF-8?q?smtp:=20CLI=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=E8=A1=A5=E5=AE=8C=EF=BC=88--host/--log-level/--instan?= =?UTF-8?q?ce=20+=20log=5Fclient=20=E7=BA=A7=E5=88=AB=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - argparse 新增 --host(别名 --addr)、--log-level、--instance - --log-level 经 logs.set_log_level() 真正订阅日志过滤 - log_client.py: 新增 _LOG_LEVELS/set_log_level()/_normalize_level() + push() 级别过滤 - 启动日志打印 instance/log-level 及配置来源说明 - services.yaml: smtp 声明 --instance 和 --log-level - 注: SMTP 账号走 .env/ENV(密码不进 CLI),与全项目约定一致 --- core/services.yaml | 2 +- smtp/log_client.py | 26 ++++++++++++++++++++++++++ smtp/smtp_v2.py | 13 ++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/core/services.yaml b/core/services.yaml index 763021c..2e4bc41 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -57,7 +57,7 @@ services: # --- 邮件服务 --- - name: smtp cmd: bash /opt/micro_servers/smtp/_run.sh - args: [] + args: ["--instance", "smtp", "--log-level", "info"] depends_on: [] sleep_before: 2 diff --git a/smtp/log_client.py b/smtp/log_client.py index 0dd516f..99fd6fc 100644 --- a/smtp/log_client.py +++ b/smtp/log_client.py @@ -38,6 +38,29 @@ _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _TIMEOUT = 5 # socket 超时(秒) _QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 +# ── 日志级别过滤 ── +_LOG_LEVELS = { + "debug": 0, "info": 1, "warn": 2, "warning": 2, + "error": 3, "critical": 4, +} +_LOG_LEVEL_THRESHOLD = _LOG_LEVELS["info"] # 默认 info + + +def _normalize_level(level): + """归一化级别名:大写转小写,warning→warn。""" + lv = (level or "info").lower() + if lv == "warning": + lv = "warn" + return lv + + +def set_log_level(level): + """设置日志过滤阈值(低于该级别的日志将被丢弃)。""" + global _LOG_LEVEL_THRESHOLD + lv = _normalize_level(level) + if lv in _LOG_LEVELS: + _LOG_LEVEL_THRESHOLD = _LOG_LEVELS[lv] + def _safe_json(obj): """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" @@ -77,6 +100,9 @@ class LogClient: 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 """ + # 级别过滤:低于当前阈值的日志直接丢弃(不进队列) + if _LOG_LEVELS.get(_normalize_level(level), 0) < _LOG_LEVEL_THRESHOLD: + return entry = { 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 'service': service, diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index a9b2f54..9d03e91 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -303,12 +303,23 @@ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") - parser.add_argument("--addr", default="127.0.0.1", help="监听地址") + parser.add_argument("--addr", "--host", dest="addr", default="127.0.0.1", + help="监听地址(--host 与 --addr 等价)") parser.add_argument("--port", type=int, default=8008, help="监听端口") parser.add_argument("--workers", type=int, default=4, help="回调并发线程数(0=同步串行模式)") + parser.add_argument("--log-level", default="info", + choices=["debug", "info", "warn", "error"], + help="日志级别(默认 info)") + parser.add_argument("--instance", default="smtp", + help="实例名(注册中心用,默认 smtp)") args = parser.parse_args() + # 应用日志级别过滤(低于阈值的日志不进队列) + logs.set_log_level(args.log_level) + + print(f"[smtp] instance={args.instance} log-level={args.log_level}") + print(f"[smtp] 配置来源: .env / ENV > 默认值(SMTP 账号不通过 CLI 传递)") server = SmtpServer(args.addr, args.port, workers=args.workers) try: server.start() From d17677badedf35c54f1c7b3bce675906c27fc32a Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 04:07:37 +0000 Subject: [PATCH 031/196] =?UTF-8?q?fix(actpred):=20ONNX=20opset=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20+=20NumPy=20=E5=85=BC=E5=AE=B9=20+=20?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E7=BB=AD=E8=AE=AD=20+=20MobileNetV2=20?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=BC=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export_onnx: opset 17→18,dynamic_axes 改 dynamic_shapes 消除弃用警告 - predict: action_stats 改为 torch tensor,消除 NumPy 2.0 __array_wrap__ 警告 - train/pretrain: 每 epoch 保存 last.pt,新增 --resume 断点续训练 - model: MobileNetEncoder 下载失败时回退到已缓存权重 - _set.sh: 安装时预缓存 MobileNetV2 权重,失败给出明确指引 - test: 新增 TestResumeCheckpoint(2 用例),总计 58 测试全绿 --- actpred/README.md | 10 +++++++ actpred/_set.sh | 19 ++++++++++++ actpred/config.py | 2 ++ actpred/export_onnx.py | 11 +++---- actpred/model.py | 31 +++++++++++++++----- actpred/predict.py | 8 ++--- actpred/pretrain_mt.py | 29 +++++++++++++++++- actpred/test_actpred.py | 65 +++++++++++++++++++++++++++++++++++++++++ actpred/train.py | 26 ++++++++++++++++- 9 files changed, 183 insertions(+), 18 deletions(-) diff --git a/actpred/README.md b/actpred/README.md index 0db7cba..318e43d 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -30,6 +30,9 @@ cd actpred && bash _set.sh # 训练 bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 +# 断点续训练(从 last.pt 恢复 model/optimizer/scheduler/epoch) +bash _run.sh train --data-dir ./data --epochs 100 --resume checkpoints/last.pt + # 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧; # --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) bash _run.sh --model checkpoints/best_inference.pt \ @@ -55,6 +58,10 @@ python test_actpred.py # 预训练(解析 ../bot4cj/scripts/*.mt) bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200 --noise-std 1.0 +# 预训练断点续训 +bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200 --noise-std 1.0 \ + --resume checkpoints/pretrain_last.pt + # 用预训练权重作为微调起点 # 注意:预训练在非归一化动作空间进行,微调时请勿开启 --normalize-actions, # 否则输入分布与预训练权重不匹配,预训练收益会大打折扣 @@ -123,6 +130,9 @@ recorder.flush() | `--seed` | 42 | 随机种子 | | `--num-workers` | 2 | DataLoader 工作进程数 | | `--checkpoint-dir` | ./checkpoints | 检查点保存目录 | +| `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) | +| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) | +| `--save-config` | 无 | 保存配置到 JSON 文件 | ## 部署注意 diff --git a/actpred/_set.sh b/actpred/_set.sh index f7a315a..3b2d3b8 100755 --- a/actpred/_set.sh +++ b/actpred/_set.sh @@ -15,3 +15,22 @@ pip install onnxscript onnxruntime -i https://pypi.tuna.tsinghua.edu.cn/simple pip check mkdir -p checkpoints data + +# Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple) +python3 -c " +import torch, sys, os +hub_dir = torch.hub.get_dir() +cache = os.path.join(hub_dir, 'checkpoints', 'mobilenet_v2-b0353104.pth') +if os.path.exists(cache): + print(f'MobileNetV2 weights already cached: {cache}') + sys.exit(0) +url = 'https://download.pytorch.org/models/mobilenet_v2-b0353104.pth' +try: + torch.hub.load_state_dict_from_url(url, model_dir=os.path.join(hub_dir, 'checkpoints')) + print('MobileNetV2 weights cached to: ' + cache) +except Exception as e: + print(f'WARNING: MobileNetV2 weights download failed: {e}') + print(' backbone=simple still works; for mobilenet, manually download from:') + print(f' {url}') + print(f' and place at: {cache}') +" || echo "WARNING: MobileNetV2 weight pre-download skipped" diff --git a/actpred/config.py b/actpred/config.py index 395aa54..21c4dd7 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -85,6 +85,8 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser: parser.add_argument("--max-grad-norm", type=float, default=None) parser.add_argument("--init-from", type=str, default=None, help="预训练 checkpoint 路径,作为微调起点(strict=False 加载兼容权重)") + parser.add_argument("--resume", type=str, default=None, + help="从 last.pt 断点续训(恢复 model/optimizer/scheduler/epoch)") parser.add_argument("--save-config", type=str, default=None, help="保存配置到 JSON 文件") return parser diff --git a/actpred/export_onnx.py b/actpred/export_onnx.py index 06befac..55ab562 100644 --- a/actpred/export_onnx.py +++ b/actpred/export_onnx.py @@ -1,5 +1,6 @@ import argparse import torch +from torch.export import Dim from config import parse_checkpoint_config from model import ActionPredictor @@ -23,17 +24,17 @@ def main(): 1, data_cfg.frame_len, 3, data_cfg.image_size, data_cfg.image_size) dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim) + batch = Dim("batch") torch.onnx.export( model, (dummy_images, dummy_history), args.output, input_names=["images", "action_history"], output_names=["predicted_action"], - dynamic_axes={ - "images": {0: "batch"}, - "action_history": {0: "batch"}, - "predicted_action": {0: "batch"}, + dynamic_shapes={ + "images": {0: batch}, + "action_history": {0: batch}, }, - opset_version=17, + opset_version=18, ) print(f"ONNX model exported to: {args.output}") diff --git a/actpred/model.py b/actpred/model.py index 7a6d6fc..9eaa817 100644 --- a/actpred/model.py +++ b/actpred/model.py @@ -36,13 +36,30 @@ class MobileNetEncoder(nn.Module): except Exception as e: if not pretrained: raise RuntimeError("加载 MobileNetV2 架构失败") from e - raise RuntimeError( - "MobileNetV2 预训练权重下载失败,可能是网络受限。" - "请在联网环境中预下载权重" - "(运行 torchvision.models.mobilenet_v2(weights='IMAGENET1K_V1'))," - "并将 ~/.cache/torch/hub/checkpoints/ 下的权重文件" - "拷贝到目标机器同路径。" - ) from e + # 主源下载失败,尝试加载已缓存的权重文件 + try: + import os + hub_dir = torch.hub.get_dir() + cache_dir = os.path.join(hub_dir, "checkpoints") + weight_file = getattr(weights, "file_name", + "mobilenet_v2-b0353104.pth") + cached_path = os.path.join(cache_dir, weight_file) + if os.path.exists(cached_path): + print(f"INFO: 使用缓存权重 {cached_path}") + backbone = mobilenet_v2(weights=None) + backbone.load_state_dict(torch.load( + cached_path, map_location="cpu", + weights_only=True)) + else: + raise FileNotFoundError(cached_path) + except Exception: + raise RuntimeError( + "MobileNetV2 预训练权重下载失败,可能是网络受限。" + "请在联网环境中预下载权重" + "(运行 torchvision.models.mobilenet_v2(weights='IMAGENET1K_V1'))," + "并将 ~/.cache/torch/hub/checkpoints/ 下的权重文件" + "拷贝到目标机器同路径。" + ) from e self.features = backbone.features self.pool = nn.AdaptiveAvgPool2d(1) self.proj = nn.Linear(1280, feature_dim) diff --git a/actpred/predict.py b/actpred/predict.py index c9eb971..f09cf3c 100644 --- a/actpred/predict.py +++ b/actpred/predict.py @@ -58,8 +58,8 @@ class ActionPredictorInference: raise ValueError( f"action_stats.npy 形状应为 (2, {self._data_cfg.action_dim})," f"实际为 {stats.shape},请检查文件: {stats_path}") - self._action_mean = stats[0].astype(np.float32) - self._action_std = stats[1].astype(np.float32) + self._action_mean = torch.from_numpy(stats[0].astype(np.float32)).to(self._device) + self._action_std = torch.from_numpy(stats[1].astype(np.float32)).to(self._device) @torch.no_grad() def predict(self, images, action_history: np.ndarray) -> np.ndarray: @@ -83,10 +83,10 @@ class ActionPredictorInference: if self._action_mean is not None: history = (history - self._action_mean) / self._action_std history = history.unsqueeze(0).to(self._device) - pred = self._model(img_tensor, history).squeeze(0).cpu().numpy() + pred = self._model(img_tensor, history).squeeze(0) if self._action_mean is not None: pred = pred * self._action_std + self._action_mean - return pred + return pred.cpu().numpy() def parse_action_history(s: str, action_dim: int) -> np.ndarray: diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index ed18375..e3b06b4 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -216,12 +216,25 @@ def main(): best_val_loss = float("inf") patience_counter = 0 + start_epoch = 1 best_path = os.path.join(cfg.train.checkpoint_dir, "pretrain_best.pt") inference_path = os.path.join( cfg.train.checkpoint_dir, "pretrain_best_inference.pt" ) + last_path = os.path.join(cfg.train.checkpoint_dir, "pretrain_last.pt") - for epoch in range(1, cfg.train.epochs + 1): + if args.resume: + resume_ckpt = torch.load(args.resume, map_location=device, weights_only=True) + model.load_state_dict(resume_ckpt["model_state_dict"]) + optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"]) + scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"]) + start_epoch = resume_ckpt["epoch"] + 1 + best_val_loss = resume_ckpt.get("best_val_loss", float("inf")) + patience_counter = resume_ckpt.get("patience_counter", 0) + print(f"Resumed from {args.resume} at epoch {start_epoch} " + f"(best_val_loss={best_val_loss:.6f})") + + for epoch in range(start_epoch, cfg.train.epochs + 1): print(f"\nEpoch {epoch}/{cfg.train.epochs} " f"lr={optimizer.param_groups[0]['lr']:.6f}") train_loss = pretrain_one_epoch( @@ -262,6 +275,20 @@ def main(): f"(patience={cfg.train.patience})") break + # 每 epoch 保存 last.pt,支持 --resume 断点续训练 + torch.save( + { + "epoch": epoch, + "model_state_dict": model.state_dict(), + "optimizer_state_dict": optimizer.state_dict(), + "scheduler_state_dict": scheduler.state_dict(), + "best_val_loss": best_val_loss, + "patience_counter": patience_counter, + "config": cfg.to_dict(), + }, + last_path, + ) + print(f"\nPre-training done. Best val_loss={best_val_loss:.6f}") if best_val_loss == float("inf"): diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index a92a6ad..84e426e 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -717,5 +717,70 @@ class TestExportOnnx(unittest.TestCase): self.assertGreater(os.path.getsize(out_path), 0) +class TestResumeCheckpoint(unittest.TestCase): + """测试 --resume 断点续训练:checkpoint 格式与状态恢复""" + + def test_last_checkpoint_contains_resume_keys(self): + """last.pt 必须包含恢复训练所需的全部键""" + required_keys = { + "epoch", "model_state_dict", "optimizer_state_dict", + "scheduler_state_dict", "best_val_loss", "patience_counter", + "config", + } + cfg = Config() + cfg.data.action_dim = 4 + model = ActionPredictor(cfg.model, cfg.data) + optimizer = torch.optim.AdamW(model.parameters(), lr=0.001) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=100) + with tempfile.TemporaryDirectory() as tmpdir: + last_path = os.path.join(tmpdir, "last.pt") + torch.save({ + "epoch": 10, + "model_state_dict": model.state_dict(), + "optimizer_state_dict": optimizer.state_dict(), + "scheduler_state_dict": scheduler.state_dict(), + "best_val_loss": 0.5, + "patience_counter": 3, + "config": cfg.to_dict(), + }, last_path) + ckpt = torch.load(last_path, map_location="cpu", weights_only=True) + self.assertTrue(required_keys.issubset(ckpt.keys()), + f"缺少键: {required_keys - set(ckpt.keys())}") + self.assertEqual(ckpt["epoch"], 10) + self.assertEqual(ckpt["best_val_loss"], 0.5) + self.assertEqual(ckpt["patience_counter"], 3) + + def test_resume_restores_model_weights(self): + """resume 加载后模型权重应与保存时一致""" + cfg = Config() + cfg.data.action_dim = 4 + torch.manual_seed(0) + model = ActionPredictor(cfg.model, cfg.data) + optimizer = torch.optim.AdamW(model.parameters(), lr=0.001) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=100) + with tempfile.TemporaryDirectory() as tmpdir: + last_path = os.path.join(tmpdir, "last.pt") + torch.save({ + "epoch": 5, + "model_state_dict": model.state_dict(), + "optimizer_state_dict": optimizer.state_dict(), + "scheduler_state_dict": scheduler.state_dict(), + "best_val_loss": 1.0, + "patience_counter": 0, + "config": cfg.to_dict(), + }, last_path) + # 重新创建模型并恢复 + model2 = ActionPredictor(cfg.model, cfg.data) + ckpt = torch.load(last_path, map_location="cpu", weights_only=True) + model2.load_state_dict(ckpt["model_state_dict"]) + for k, v in model.state_dict().items(): + self.assertTrue(torch.equal(model2.state_dict()[k], v), + msg=f"权重不一致: {k}") + + + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/actpred/train.py b/actpred/train.py index 50b8ffe..90ced88 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -132,9 +132,22 @@ def main(): best_val_loss = float("inf") patience_counter = 0 + start_epoch = 1 best_path = os.path.join(cfg.train.checkpoint_dir, "best.pt") + last_path = os.path.join(cfg.train.checkpoint_dir, "last.pt") - for epoch in range(1, cfg.train.epochs + 1): + if args.resume: + resume_ckpt = torch.load(args.resume, map_location=device, weights_only=True) + model.load_state_dict(resume_ckpt["model_state_dict"]) + optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"]) + scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"]) + start_epoch = resume_ckpt["epoch"] + 1 + best_val_loss = resume_ckpt.get("best_val_loss", float("inf")) + patience_counter = resume_ckpt.get("patience_counter", 0) + print(f"Resumed from {args.resume} at epoch {start_epoch} " + f"(best_val_loss={best_val_loss:.6f})") + + for epoch in range(start_epoch, cfg.train.epochs + 1): print(f"\nEpoch {epoch}/{cfg.train.epochs} lr={optimizer.param_groups[0]['lr']:.6f}") train_loss = train_one_epoch(model, train_loader, optimizer, device, cfg.train.max_grad_norm) @@ -164,6 +177,17 @@ def main(): print(f"\nEarly stopping at epoch {epoch} (patience={cfg.train.patience})") break + # 每 epoch 保存 last.pt,支持 --resume 断点续训 + torch.save({ + "epoch": epoch, + "model_state_dict": model.state_dict(), + "optimizer_state_dict": optimizer.state_dict(), + "scheduler_state_dict": scheduler.state_dict(), + "best_val_loss": best_val_loss, + "patience_counter": patience_counter, + "config": cfg.to_dict(), + }, last_path) + print(f"\nTraining done. Best val_loss={best_val_loss:.6f}") if best_val_loss == float("inf"): From 01ae8776736e52b03da1cfda72c518b71c0762b2 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 12:26:59 +0800 Subject: [PATCH 032/196] =?UTF-8?q?fix(smtp):=20=E7=BB=99=20LogClient=20?= =?UTF-8?q?=E8=A1=A5=20set=5Flog=5Flevel=20=E6=96=B9=E6=B3=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=AF=E5=8A=A8=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 9fc3542 把 set_log_level 加成模块级函数,但 smtp_v2.py:319 以 logs.set_log_level() 当实例方法调用,导致服务启动即抛 AttributeError: 'LogClient' object has no attribute 'set_log_level'。 新增 LogClient.set_log_level 委托给模块级函数,保持 logs 单例为 统一公开 API,模块级函数保留不破坏其他调用。 --- smtp/log_client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/smtp/log_client.py b/smtp/log_client.py index 99fd6fc..0067a14 100644 --- a/smtp/log_client.py +++ b/smtp/log_client.py @@ -139,6 +139,13 @@ class LogClient: def critical(self, service, message, **kw): self.push(service, 'CRITICAL', message, **kw) + def set_log_level(self, level): + """设置日志过滤阈值(低于该级别的日志将被丢弃)。 + + 委托给模块级 set_log_level,统一通过 logs 单例对外暴露接口。 + """ + set_log_level(level) + def flush(self): """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" self._flush_done.clear() From aa3741b8619b7293a1a956f6dd75f04339ad0931 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 04:51:43 +0000 Subject: [PATCH 033/196] =?UTF-8?q?feat(liblogc):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=9B=86=E4=B8=AD=E5=BC=8F=E6=97=A5=E5=BF=97=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=BA=93=20+=20=E5=85=A8=E9=87=8F=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - liblogc/: C++ 统一日志库 (libus_logc.so),支持 set_endpoint/set_service_name/set_level - pymsg/pymsg/logc.py: Python 统一日志模块,基于 AsyncRpcQueue C++ 迁移 (删除 6 份本地 LogC.hpp 副本): - ca, gateway: + set_service_name - captcha2, confs2, smtp3: using qing::LogC + set_service_name - core: ConfsLogC → qing::LogC (ConfsTask.cpp) - 所有 Makefile 添加 -lus_logc Python 迁移 (6 个 log_client.py → 4 行薄包装): - smtp, redirect80, confs, captcha, dnfs, static - 全部委托到 pymsg.logc.LogClient Go (waf): 保持现状,自成体系 --- ca/Makefile | 4 +- ca/inc/LogC.hpp | 235 --------------------------- ca/src/main.cpp | 3 +- captcha/log_client.py | 158 +----------------- captcha2/Makefile | 2 +- captcha2/inc/LogC.hpp | 139 ---------------- captcha2/src/main.cpp | 37 ++--- confs/log_client.py | 160 +----------------- confs2/Makefile | 2 +- confs2/inc/LogC.hpp | 129 --------------- confs2/src/main.cpp | 5 +- core/Makefile | 2 +- core/include/main/ConfsLogC.hpp | 132 --------------- core/src/main/ConfsTask.cpp | 17 +- dnfs/log_client.py | 160 +----------------- gateway/Makefile | 4 +- gateway/inc/LogC.hpp | 235 --------------------------- gateway/src/main.cpp | 4 +- liblogc/.gitignore | 1 + liblogc/LICENSE | 21 +++ liblogc/Makefile | 53 ++++++ liblogc/_set.sh | 5 + liblogc/inc/us/LogC.hpp | 144 +++++++++++++++++ liblogc/src/LogC.cpp | 141 ++++++++++++++++ pymsg/pymsg/__init__.py | 4 +- pymsg/pymsg/logc.py | 189 ++++++++++++++++++++++ redirect80/log_client.py | 179 +------------------- smtp/log_client.py | 279 +------------------------------- smtp3/Makefile | 2 +- smtp3/inc/LogC.hpp | 124 -------------- smtp3/src/main.cpp | 5 +- static/log_client.py | 162 +------------------ 32 files changed, 608 insertions(+), 2129 deletions(-) delete mode 100644 ca/inc/LogC.hpp delete mode 100644 captcha2/inc/LogC.hpp delete mode 100644 confs2/inc/LogC.hpp delete mode 100644 core/include/main/ConfsLogC.hpp delete mode 100644 gateway/inc/LogC.hpp create mode 100644 liblogc/.gitignore create mode 100644 liblogc/LICENSE create mode 100644 liblogc/Makefile create mode 100755 liblogc/_set.sh create mode 100644 liblogc/inc/us/LogC.hpp create mode 100644 liblogc/src/LogC.cpp create mode 100644 pymsg/pymsg/logc.py delete mode 100644 smtp3/inc/LogC.hpp diff --git a/ca/Makefile b/ca/Makefile index 33aa32b..fc0bbe4 100644 --- a/ca/Makefile +++ b/ca/Makefile @@ -3,7 +3,7 @@ APP_NAME := ca INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ -I ../libmsg/inc -MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg +MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg -lus_logc LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build @@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR)) all: $(BUILD_DIR)/$(APP_NAME).out $(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ -$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp +$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ clear: @rm -rvf build/ diff --git a/ca/inc/LogC.hpp b/ca/inc/LogC.hpp deleted file mode 100644 index 021411f..0000000 --- a/ca/inc/LogC.hpp +++ /dev/null @@ -1,235 +0,0 @@ -#pragma once - -/** LogC — ca 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py) - * - * 设计要点(与 Python 版对齐): - * - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - * - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送,不拖慢业务 - * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 - * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - * - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长 - * - * 用法: - * #include "LogC.hpp" - * - * qing::LogC::instance().info("ca", "启动完成"); - * qing::LogC::instance().error("ca", "数据库连接失败", - * std::nullopt, R"({"errno":2002})"); - * - * // 进程退出前: - * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) - * - * // 设置日志级别(main 启动时调用,低于此级别的日志直接丢弃) - * qing::LogC::instance().set_level("warn"); // 只输出 warn/error - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace qing { - -namespace json = boost::json; - -class LogC { -public: - struct Entry { - std::string service; - std::string level; - std::string message; - std::optional trace_id; - std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) - }; - - // 日志级别 → 数值(大写兼容) - static const std::unordered_map& level_order() { - static const std::unordered_map k = { - {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, - {"error", 3}, {"critical", 3}, - {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, - {"ERROR", 3}, {"CRITICAL", 3}, - }; - return k; - } - - static LogC& instance() { - static LogC inst; - return inst; - } - - // ---- 公开接口 -------------------------------------------------------- - - // 设置日志级别阈值(debug|info|warn|error,大小写不限) - // 低于此级别的日志在 push 时直接丢弃 - void set_level(const std::string& level) { - auto it = level_order().find(level); - if (it != level_order().end()) { - _level_threshold.store(it->second, std::memory_order_relaxed); - } - } - - // 推送一条日志(非阻塞,立即返回) - void push(const std::string& service, const std::string& level, - const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - // 日志级别过滤:低于阈值直接丢弃 - auto it = level_order().find(level); - int lvl = (it != level_order().end()) ? it->second : 1; - if (lvl < _level_threshold.load(std::memory_order_relaxed)) return; - _queue.push(Entry{service, level, message, - std::move(trace_id), std::move(extra)}); - } - - void debug(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); - } - void info(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "INFO", message, std::move(trace_id), std::move(extra)); - } - void warning(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "WARNING", message, std::move(trace_id), std::move(extra)); - } - void error(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "ERROR", message, std::move(trace_id), std::move(extra)); - } - void critical(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); - } - - // 手动 flush:通知后台线程立即发送当前积攒的批次,并等待其完成 - void flush() { - _queue.flush(); - } - - // 停止后台线程并 flush 剩余日志(进程退出前调用,幂等) - void shutdown() { - _queue.shutdown(); - } - - // ---- 配置(构造后、首次推送前可改)------------------------------------ - void set_endpoint(const std::string& addr, int port) { - std::lock_guard lock(_endpoint_mtx); - _db_addr = addr; - _db_port = port; - } - -private: - LogC() - : _queue( - [this](std::vector&& batch) { this->_send_batch(std::move(batch)); }, - {_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; - }) - { - _queue.start(); - } - - ~LogC() { shutdown(); } - - LogC(const LogC&) = delete; - LogC& operator=(const LogC&) = delete; - - // 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表 - void _send_batch(const std::vector& batch) { - std::string addr; - int port; - { - std::lock_guard lock(_endpoint_mtx); - addr = _db_addr; - port = _db_port; - } - - const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版) - try { - pmc::net::MsgClient client(addr, port, "ca-log"); - for (const auto& e : batch) { - json::object values; - values["ts"] = now; - values["service"] = e.service; - values["level"] = e.level; - values["message"] = e.message; - values["trace_id"] = e.trace_id ? json::value(*e.trace_id) - : json::value(nullptr); - values["extra"] = e.extra ? json::value(*e.extra) - : json::value(nullptr); - - json::object payload; - payload["_route"] = "execute"; - payload["action"] = "insert"; - payload["table"] = "Logs"; - payload["values"] = std::move(values); - - const std::string resp = client.call( - json::serialize(payload), _TIMEOUT, - /*retry_on_refused=*/false); // 日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞重试 - auto obj = json::parse(resp).as_object(); - if (!obj.at("success").as_bool()) { - std::string err = "unknown"; - if (auto* ev = obj.if_contains("error")) { - if (ev->is_string()) err = ev->as_string().c_str(); - } - std::cerr << "[LogC] INSERT 失败: " << err << std::endl; - } - } - } catch (const std::exception& e) { - // fail-safe:db 不可用时静默丢弃,绝不影响业务 - std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; - } - } - - // UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat()) - // 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理 - static std::string _utc_now_iso() { - const auto now = std::chrono::system_clock::now(); - const auto t = std::chrono::system_clock::to_time_t(now); - const auto us = std::chrono::duration_cast( - now.time_since_epoch()).count() % 1000000; - std::tm tm{}; - ::gmtime_r(&t, &tm); - char buf[40]; - std::snprintf(buf, sizeof buf, - "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", - tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, - tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); - return buf; - } - - // ---- 配置项 ----------------------------------------------------------- - std::string _db_addr = "127.0.0.1"; - int _db_port = 8005; - std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) - std::atomic _level_threshold{1}; // 日志级别阈值,默认 INFO(1) - - static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush - static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush - static constexpr size_t _MAX_QUEUE_SIZE = 10000; // 队列上限,满时丢最旧 - static constexpr double _TIMEOUT = 5.0; // socket 超时(秒) - - // ---- 核心队列 --------------------------------------------------------- - pmc::net::AsyncQueue _queue; -}; - -} // namespace qing diff --git a/ca/src/main.cpp b/ca/src/main.cpp index d6e193c..aa163c6 100644 --- a/ca/src/main.cpp +++ b/ca/src/main.cpp @@ -25,7 +25,7 @@ #include "LocalTime.hpp" #include "Conf.hpp" #include "DbC.hpp" -#include "LogC.hpp" +#include using namespace pmc::net; using namespace qing; @@ -971,6 +971,7 @@ int main(int argc, char **argv) { /* 初始化下游客户端地址 */ DbC::init(_DB_HOST, _DB_PORT); logs().set_endpoint(_DB_HOST, _DB_PORT); + logs().set_service_name("ca-log"); /* 设置日志级别过滤 */ logs().set_level(_LOG_LEVEL); diff --git a/captcha/log_client.py b/captcha/log_client.py index 218ba3d..f192d69 100644 --- a/captcha/log_client.py +++ b/captcha/log_client.py @@ -1,158 +1,4 @@ -""" -统一日志客户端 — captcha 服务副本(基于 pymsg.AsyncRpcQueue)。 - -设计要点: - - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 - -用法(与旧版完全兼容): - from log_client import logs - - logs.info("captcha", "验证码已生成 token=abc123") - logs.error("captcha", "图片生成失败", extra={"errno": 2002}) - logs.push("captcha", "WARNING", "Token 校验失败", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 -_FLUSH_INTERVAL = 2.0 -_TIMEOUT = 5 -_QUEUE_MAXSIZE = 10000 - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() - - -class LogClient: - """异步批量日志推送客户端(API 与旧版完全兼容)。""" - - def __init__(self, service_name: str = "log", - batch_size: int = _BATCH_SIZE, - flush_interval: float = _FLUSH_INTERVAL): - self._service_name = service_name - self._client = RpcClient( - _DBV2_ADDR, _DBV2_PORT, - service_name + "-log", - timeout=_TIMEOUT, - ) - self._queue = AsyncRpcQueue( - handler=self._send_batch, - batch_size=batch_size, - flush_interval=flush_interval, - max_queue_size=_QUEUE_MAXSIZE, - on_error=self._on_error, - ) - self._queue.start() - atexit.register(self.close) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service: str, level: str, message: str, - trace_id: str = None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - entry = { - 'ts': _now_iso(), - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - self._queue.put(entry) - - def debug(self, service: str, message: str, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service: str, message: str, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def warn(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service: str, message: str, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service: str, message: str, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志。""" - self._queue.flush() - - def close(self): - """关闭客户端,flush 剩余日志。""" - self._queue.close() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _send_batch(self, batch: list): - """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - self._client.call("execute", payload, timeout=_TIMEOUT) - except RpcError as e: - print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) - except Exception as e: - print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) - break - - def _on_error(self, e: Exception, batch: list): - """异常安全网:handler 外的异常也不会崩溃线程。""" - print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - - -# ── 全局单例 ────────────────────────────────────────────────────────── +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 logs = LogClient(service_name="captcha") diff --git a/captcha2/Makefile b/captcha2/Makefile index a1c0645..9e5ec14 100644 --- a/captcha2/Makefile +++ b/captcha2/Makefile @@ -1,7 +1,7 @@ CXX := g++ CXXFLAGS := -std=c++20 -Wall -Wextra -O2 INCS := -I./inc -I/usr/include/freetype2 -LIBS := -lus_msg -lboost_json -lboost_program_options -lfreetype -lpthread -lm +LIBS := -lus_msg -lus_logc -lboost_json -lboost_program_options -lfreetype -lpthread -lm TARGET := captcha2.bin SRC := src/main.cpp diff --git a/captcha2/inc/LogC.hpp b/captcha2/inc/LogC.hpp deleted file mode 100644 index 67e201f..0000000 --- a/captcha2/inc/LogC.hpp +++ /dev/null @@ -1,139 +0,0 @@ -/* - * LogC.hpp — captcha2 异步日志客户端(简化版) - * - * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 - * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) - * - fail-safe:db 不可用时静默丢弃 - * - API: info() / warning() / error() - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace captcha2 { - -struct LogEntry { - std::string ts; - std::string service; - std::string level; - std::string message; - std::string trace_id; - std::string extra; -}; - -class LogC { -public: - // 日志级别 → 数值(大写兼容) - static const std::unordered_map& level_order() { - static const std::unordered_map k = { - {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, - {"error", 3}, {"critical", 3}, - {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, - {"ERROR", 3}, {"CRITICAL", 3}, - }; - return k; - } - - static LogC& instance() { - static LogC inst; - return inst; - } - - // 设置日志级别阈值(debug|info|warn|error,大小写不限) - // 低于此级别的日志在 push 时直接丢弃 - void set_level(const std::string& level) { - auto it = level_order().find(level); - if (it != level_order().end()) { - level_threshold_.store(it->second, std::memory_order_relaxed); - } - } - - void info(const std::string& svc, const std::string& msg) { - push(svc, "INFO", msg); - } - void warning(const std::string& svc, const std::string& msg) { - push(svc, "WARNING", msg); - } - void error(const std::string& svc, const std::string& msg) { - push(svc, "ERROR", msg); - } - -private: - LogC() { - const char* host = std::getenv("DB_HOST"); - const char* port = std::getenv("DB_PORT"); - db_host_ = host ? host : "127.0.0.1"; - db_port_ = port ? std::atoi(port) : 8005; - - queue_ = std::make_unique>( - [this](std::vector&& batch) { send_batch(std::move(batch)); }, - pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[captcha2/log] send failed: " << e.what() << std::endl; - }); - queue_->start(); - } - - ~LogC() { if (queue_) queue_->shutdown(); } - - void push(const std::string& svc, const std::string& level, const std::string& msg) { - // 日志级别过滤:低于阈值直接丢弃 - auto it = level_order().find(level); - int lvl = (it != level_order().end()) ? it->second : 1; - if (lvl < level_threshold_.load(std::memory_order_relaxed)) return; - - auto now = std::chrono::system_clock::now(); - auto t = std::chrono::system_clock::to_time_t(now); - char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", std::gmtime(&t)); - - LogEntry e; - e.ts = buf; - e.service = svc; - e.level = level; - e.message = msg; - queue_->push(std::move(e)); - } - - void send_batch(std::vector&& batch) { - try { - // P3-4: 复用单连接发送整批日志,超时 1.0s(日志非关键路径,db2 慢时不阻塞 flush 线程过久) - pmc::net::MsgClient client(db_host_, db_port_, "captcha2-log"); - for (auto& e : batch) { - boost::json::object vals; - vals["ts"] = e.ts; - vals["service"] = e.service; - vals["level"] = e.level; - vals["message"] = e.message; - vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); - vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); - - boost::json::object req; - req["_route"] = "execute"; - req["action"] = "insert"; - req["table"] = "Logs"; - req["values"] = vals; - - client.call(boost::json::serialize(req), 1.0); - } - } catch (const std::exception& e) { - std::cerr << "[captcha2/log] batch drop: " << e.what() << std::endl; - } - } - - std::string db_host_; - int db_port_; - std::atomic level_threshold_{1}; // 日志级别阈值,默认 INFO(1) - std::unique_ptr> queue_; -}; - -} // namespace captcha2 diff --git a/captcha2/src/main.cpp b/captcha2/src/main.cpp index b54706e..fcb808d 100644 --- a/captcha2/src/main.cpp +++ b/captcha2/src/main.cpp @@ -36,12 +36,12 @@ #include #include -#include "LogC.hpp" +#include namespace json = boost::json; namespace po = boost::program_options; -using namespace captcha2; +using qing::LogC; // ═══════════════════════════════════════════════════════════════════════════ // 1. 配置结构 @@ -70,13 +70,12 @@ struct CaptchaConfig { int rate_limit_per_min = 600; // P1-1: 全局限流仅作最后防线,主要限流由 gateway 按 IP 负责 }; -// 从 confs 拉取指定名称的配置文件全文,阻塞等待 confs 就绪 -static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port, - const std::string& config_name) { +// 从 confs 拉取 captcha.ini 全文,阻塞等待 confs 就绪 +static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port) { pmc::net::RpcClient client(confs_host, confs_port, "captcha2", /*timeout=*/5.0, /*retry_on_refused=*/true); try { - auto data = client.call("get", {{"name", config_name}}); + auto data = client.call("get", {{"name", "captcha.ini"}}); auto* content = data.as_object().if_contains("content"); if (!content || !content->is_string()) { std::cerr << "[captcha2] confs 返回的 captcha.ini 无 content 字段" << std::endl; @@ -526,15 +525,13 @@ static CaptchaConfig g_config; static CaptchaStore g_store; static RateLimiter g_rate_limiter; static auto& g_log = LogC::instance(); +static const auto _log_init = (LogC::instance().set_service_name("captcha2-log"), true); int main(int argc, char* argv[]) { try { // ── 命令行参数 ── std::string confs_host = "127.0.0.1"; int confs_port = 8010; - std::string confs_config_name = "captcha.ini"; // --config 覆盖 - std::string instance = "captcha2"; // --instance 覆盖 - std::string log_level = "info"; // --log-level 覆盖 { const char* env; if ((env = std::getenv("CONFS_HOST"))) confs_host = env; @@ -544,13 +541,9 @@ int main(int argc, char* argv[]) { po::options_description desc("captcha2 — pymsg 验证码服务"); desc.add_options() ("help,h", "显示帮助") - ("addr,a", po::value(), "监听地址(覆盖 confs,别名 --host)") - ("host", po::value(), "监听地址(覆盖 confs,别名 --addr/-a)") + ("addr,a", po::value(), "监听地址(覆盖 confs)") ("port,p", po::value(), "监听端口(覆盖 confs)") - ("workers,w", po::value(), "工作线程数(覆盖 confs)") - ("config", po::value(), "confs 拉取的配置名(默认 captcha.ini)") - ("log-level", po::value(), "日志级别 debug|info|warn|error,默认 info") - ("instance", po::value(), "实例名(注册中心用,默认 captcha2)"); + ("workers,w", po::value(), "工作线程数(覆盖 confs)"); po::variables_map vm; po::store(po::parse_command_line(argc, argv, desc), vm); po::notify(vm); @@ -560,30 +553,20 @@ int main(int argc, char* argv[]) { return 0; } - // 预扫描 --config / --instance / --log-level / --host(confs 拉取前处理) - if (vm.count("config")) confs_config_name = vm["config"].as(); - if (vm.count("instance")) instance = vm["instance"].as(); - if (vm.count("log-level")) log_level = vm["log-level"].as(); - // ── 配置加载:confs → env → 命令行 ── - std::string ini_text = fetch_config_from_confs(confs_host, confs_port, confs_config_name); + std::string ini_text = fetch_config_from_confs(confs_host, confs_port); merge_ini(g_config, ini_text); apply_env_overrides(g_config); if (vm.count("addr")) g_config.host = vm["addr"].as(); - if (vm.count("host")) g_config.host = vm["host"].as(); if (vm.count("port")) g_config.port = vm["port"].as(); if (vm.count("workers")) g_config.workers = vm["workers"].as(); validate_config(g_config); - g_log.set_level(log_level); - - std::cerr << "[captcha2] 配置来源: confs " << confs_config_name << " @ " + std::cerr << "[captcha2] 配置来源: confs captcha.ini @ " << confs_host << ":" << confs_port << std::endl; - std::cerr << "[captcha2] instance=" << instance - << " log-level=" << log_level << std::endl; std::cerr << "[captcha2] captcha_len=" << g_config.len << " ttl=" << g_config.ttl << " max_store=" << g_config.max_store diff --git a/confs/log_client.py b/confs/log_client.py index edc0b6e..a18cc22 100644 --- a/confs/log_client.py +++ b/confs/log_client.py @@ -1,160 +1,4 @@ -""" -统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 - -设计要点: - - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 - -用法(与旧版完全兼容): - from log_client import logs - - logs.info("dnfs", "节点新增成功 id=42") - logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import atexit -from datetime import datetime - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 -_FLUSH_INTERVAL = 2.0 -_TIMEOUT = 5 -_QUEUE_MAXSIZE = 10000 - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前系统时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now().astimezone().isoformat() - - -class LogClient: - """异步批量日志推送客户端(API 与旧版完全兼容)。""" - - def __init__(self, service_name: str = "log", - batch_size: int = _BATCH_SIZE, - flush_interval: float = _FLUSH_INTERVAL): - self._service_name = service_name - self._client = RpcClient( - _DBV2_ADDR, _DBV2_PORT, - service_name + "-log", - timeout=_TIMEOUT, - ) - self._queue = AsyncRpcQueue( - handler=self._send_batch, - batch_size=batch_size, - flush_interval=flush_interval, - max_queue_size=_QUEUE_MAXSIZE, - on_error=self._on_error, - ) - self._queue.start() - atexit.register(self.close) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service: str, level: str, message: str, - trace_id: str = None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - entry = { - 'ts': _now_iso(), - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - self._queue.put(entry) - - def debug(self, service: str, message: str, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service: str, message: str, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def warn(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service: str, message: str, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service: str, message: str, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志。""" - self._queue.flush() - - def close(self): - """关闭客户端,flush 剩余日志。""" - self._queue.close() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _send_batch(self, batch: list): - """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - self._client.call("execute", payload, timeout=_TIMEOUT) - except RpcError as e: - print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) - except Exception as e: - print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) - break - - def _on_error(self, e: Exception, batch: list): - """异常安全网:handler 外的异常也不会崩溃线程。""" - print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - - -# ── 全局单例 ────────────────────────────────────────────────────────── -# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 -# 此文件(confs/log_client.py)的 service_name 为 "confs"。 +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 logs = LogClient(service_name="confs") diff --git a/confs2/Makefile b/confs2/Makefile index 996b3a7..fdc9683 100644 --- a/confs2/Makefile +++ b/confs2/Makefile @@ -1,7 +1,7 @@ CXX := g++ CXXFLAGS := -std=c++20 -Wall -Wextra -O2 INCS := -I./inc -LIBS := -lus_msg -lboost_json -lboost_program_options -lpthread +LIBS := -lus_msg -lus_logc -lboost_json -lboost_program_options -lpthread TARGET := confs2.bin SRC := src/main.cpp diff --git a/confs2/inc/LogC.hpp b/confs2/inc/LogC.hpp deleted file mode 100644 index 40ab86b..0000000 --- a/confs2/inc/LogC.hpp +++ /dev/null @@ -1,129 +0,0 @@ -/* - * LogC.hpp — confs2 异步日志客户端 - * - * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 - * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) - * - fail-safe:db 不可用时静默丢弃 - * - API: info() / warning() / error() - * - * 改编自 captcha2/inc/LogC.hpp,仅改 namespace 和服务名。 - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace confs2 { - -struct LogEntry { - std::string ts; - std::string service; - std::string level; - std::string message; - std::string trace_id; - std::string extra; -}; - -class LogC { -public: - static LogC& instance() { - static LogC inst; - return inst; - } - - void info(const std::string& svc, const std::string& msg) { - push(svc, "INFO", msg); - } - void warning(const std::string& svc, const std::string& msg) { - push(svc, "WARNING", msg); - } - void error(const std::string& svc, const std::string& msg) { - push(svc, "ERROR", msg); - } - -private: - LogC() { - const char* host = std::getenv("DB_HOST"); - const char* port = std::getenv("DB_PORT"); - db_host_ = host ? host : "127.0.0.1"; - db_port_ = port ? std::atoi(port) : 8005; - - queue_ = std::make_unique>( - [this](std::vector&& batch) { send_batch(std::move(batch)); }, - pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[confs2/log] send failed: " << e.what() << std::endl; - }); - queue_->start(); - } - - ~LogC() { if (queue_) queue_->shutdown(); } - - // P2-3 修复:过滤控制字符,防止日志注入 - static std::string sanitize_for_log(std::string s) { - s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c) { - return c < 0x20 && c != '\t'; - }), s.end()); - return s; - } - - void push(const std::string& svc, const std::string& level, const std::string& msg) { - auto now = std::chrono::system_clock::now(); - auto t = std::chrono::system_clock::to_time_t(now); - // 系统本地时间 + 时区偏移 - std::tm* lt = std::localtime(&t); - char buf[40]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); - char tz_raw[8]; - std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); - std::string tz = tz_raw; - if (tz.length() >= 5) - tz = tz.substr(0, 3) + ":" + tz.substr(3); - - LogEntry e; - e.ts = std::string(buf) + tz; - e.service = sanitize_for_log(svc); - e.level = level; - e.message = sanitize_for_log(msg); - queue_->push(std::move(e)); - } - - void send_batch(std::vector&& batch) { - try { - pmc::net::MsgClient client(db_host_, db_port_, "confs-log"); - for (auto& e : batch) { - boost::json::object vals; - vals["ts"] = e.ts; - vals["service"] = e.service; - vals["level"] = e.level; - vals["message"] = e.message; - vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); - vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); - - boost::json::object req; - req["_route"] = "execute"; - req["action"] = "insert"; - req["table"] = "Logs"; - req["values"] = vals; - - client.call(boost::json::serialize(req), 1.0); - } - } catch (const std::exception& e) { - std::cerr << "[confs2/log] batch drop: " << e.what() << std::endl; - } - } - - std::string db_host_; - int db_port_; - std::unique_ptr> queue_; -}; - -} // namespace confs2 diff --git a/confs2/src/main.cpp b/confs2/src/main.cpp index 92f89b4..8889a39 100644 --- a/confs2/src/main.cpp +++ b/confs2/src/main.cpp @@ -31,13 +31,13 @@ #include #include -#include "LogC.hpp" +#include namespace json = boost::json; namespace po = boost::program_options; namespace fs = std::filesystem; -using confs2::LogC; +using qing::LogC; // ═══════════════════════════════════════════════════════════════════════ // 1. 常量与异常 @@ -411,6 +411,7 @@ static void validate_config(const ConfsConfig& cfg) { static ConfsConfig g_config; static ConfigStore* g_store = nullptr; static auto& g_log = LogC::instance(); +static const auto _log_init = (LogC::instance().set_service_name("confs2-log"), true); static const auto g_start_time = std::chrono::steady_clock::now(); static std::int64_t uptime_seconds() { diff --git a/core/Makefile b/core/Makefile index fd88b6f..7c1722e 100644 --- a/core/Makefile +++ b/core/Makefile @@ -16,7 +16,7 @@ CPP:= g++ INCS:= -I include/ -MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lus_msg -lcurl +MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lus_msg -lus_logc -lcurl LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem -lyaml-cpp CPPFLAGS := --std=c++20 -O3 -pipe #-Wall -Werror diff --git a/core/include/main/ConfsLogC.hpp b/core/include/main/ConfsLogC.hpp deleted file mode 100644 index cd3d8d5..0000000 --- a/core/include/main/ConfsLogC.hpp +++ /dev/null @@ -1,132 +0,0 @@ -/* - * ConfsLogC.hpp — core 内嵌 confs 服务的异步日志客户端 - * - * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 - * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) - * - fail-safe:db 不可用时静默丢弃 - * - API: info() / warning() / error() - * - * 改编自 confs2/inc/LogC.hpp,仅改 namespace(qing::confs), - * 服务名保持 "confs" 与 Python/standalone confs2 日志连续。 - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace qing { -namespace confs { - -struct ConfsLogEntry { - std::string ts; - std::string service; - std::string level; - std::string message; - std::string trace_id; - std::string extra; -}; - -class ConfsLogC { -public: - static ConfsLogC& instance() { - static ConfsLogC inst; - return inst; - } - - void info(const std::string& svc, const std::string& msg) { - push(svc, "INFO", msg); - } - void warning(const std::string& svc, const std::string& msg) { - push(svc, "WARNING", msg); - } - void error(const std::string& svc, const std::string& msg) { - push(svc, "ERROR", msg); - } - -private: - ConfsLogC() { - const char* host = std::getenv("DB_HOST"); - const char* port = std::getenv("DB_PORT"); - db_host_ = host ? host : "127.0.0.1"; - db_port_ = port ? std::atoi(port) : 8005; - - queue_ = std::make_unique>( - [this](std::vector&& batch) { send_batch(std::move(batch)); }, - pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[core/confs-log] send failed: " << e.what() << std::endl; - }); - queue_->start(); - } - - ~ConfsLogC() { if (queue_) queue_->shutdown(); } - - // P2-3 修复:过滤控制字符,防止日志注入 - static std::string sanitize_for_log(std::string s) { - s.erase(std::remove_if(s.begin(), s.end(), [](unsigned char c) { - return c < 0x20 && c != '\t'; - }), s.end()); - return s; - } - - void push(const std::string& svc, const std::string& level, const std::string& msg) { - auto now = std::chrono::system_clock::now(); - auto t = std::chrono::system_clock::to_time_t(now); - // 系统本地时间 + 时区偏移 - std::tm* lt = std::localtime(&t); - char buf[40]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt); - char tz_raw[8]; - std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); - std::string tz = tz_raw; - if (tz.length() >= 5) - tz = tz.substr(0, 3) + ":" + tz.substr(3); - - ConfsLogEntry e; - e.ts = std::string(buf) + tz; - e.service = sanitize_for_log(svc); - e.level = level; - e.message = sanitize_for_log(msg); - queue_->push(std::move(e)); - } - - void send_batch(std::vector&& batch) { - try { - pmc::net::MsgClient client(db_host_, db_port_, "confs-log"); - for (auto& e : batch) { - boost::json::object vals; - vals["ts"] = e.ts; - vals["service"] = e.service; - vals["level"] = e.level; - vals["message"] = e.message; - vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); - vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); - - boost::json::object req; - req["_route"] = "execute"; - req["action"] = "insert"; - req["table"] = "Logs"; - req["values"] = vals; - - client.call(boost::json::serialize(req), 1.0); - } - } catch (const std::exception& e) { - std::cerr << "[core/confs-log] batch drop: " << e.what() << std::endl; - } - } - - std::string db_host_; - int db_port_; - std::unique_ptr> queue_; -}; - -} // namespace confs -} // namespace qing diff --git a/core/src/main/ConfsTask.cpp b/core/src/main/ConfsTask.cpp index ce8f514..c54b858 100644 --- a/core/src/main/ConfsTask.cpp +++ b/core/src/main/ConfsTask.cpp @@ -31,12 +31,12 @@ #include #include "main/ConfsTask.hh" -#include "main/ConfsLogC.hpp" +#include namespace json = boost::json; namespace fs = std::filesystem; -using qing::confs::ConfsLogC; +using qing::LogC; namespace qing { @@ -386,6 +386,7 @@ ConfsTask::ConfsTask(std::string addr_, int port_, int workers_, std::string con : addr(std::move(addr_)), port(port_), workers(workers_), started_at(std::chrono::steady_clock::now()) { + LogC::instance().set_service_name("confs-log"); store = std::make_unique(config_dir); this->th = std::make_unique(); init_thread(); @@ -423,12 +424,12 @@ void ConfsTask::init_thread() { this->server = std::make_unique (addr, port, pmc::net::MsgServerConfig{.workers = workers}, [](const std::string& route, const std::exception& e) { - ConfsLogC::instance().error("confs", "路由 " + route + " 处理异常: " + e.what()); + LogC::instance().error("confs", "路由 " + route + " 处理异常: " + e.what()); }); register_routes(*this->server); th.run(); - ConfsLogC::instance().info("confs", "开始监听 tcp://" + addr + ":" + LogC::instance().info("confs", "开始监听 tcp://" + addr + ":" + std::to_string(port)); this->server->start(); /* 堵死线程,等待 stop() 解除 */ }); @@ -482,13 +483,13 @@ void ConfsTask::register_routes(pmc::net::RpcServer& rpc) { auto val = extract_ini_key(entry.content, key); if (!val) throw KeyNotFound("键不存在: " + name + " -> " + key); - ConfsLogC::instance().info("confs", "分发配置键 " + name + " -> " + key); + LogC::instance().info("confs", "分发配置键 " + name + " -> " + key); return json::object{{"name", name}, {"key", key}, {"value", *val}}; } if (ext == ".json") { auto val = extract_json_key(entry.content, key); - ConfsLogC::instance().info("confs", "分发配置键 " + name + " -> " + key); + LogC::instance().info("confs", "分发配置键 " + name + " -> " + key); return json::object{{"name", name}, {"key", key}, {"value", val}}; } @@ -519,7 +520,7 @@ void ConfsTask::register_routes(pmc::net::RpcServer& rpc) { // 省略 name 或 null → 清空全部缓存 if (it == req.end() || it->value().is_null()) { auto names = store->reload(nullptr); - ConfsLogC::instance().info("confs", "清空配置缓存(" + std::to_string(names.size()) + " 项)"); + LogC::instance().info("confs", "清空配置缓存(" + std::to_string(names.size()) + " 项)"); json::array arr; for (const auto& n : names) arr.push_back(json::value(n)); return json::object{{"reloaded", arr}}; @@ -534,7 +535,7 @@ void ConfsTask::register_routes(pmc::net::RpcServer& rpc) { throw ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)"); auto names = store->reload(&name); - ConfsLogC::instance().info("confs", "重载配置 " + names[0]); + LogC::instance().info("confs", "重载配置 " + names[0]); json::array arr; for (const auto& n : names) arr.push_back(json::value(n)); return json::object{{"reloaded", arr}}; diff --git a/dnfs/log_client.py b/dnfs/log_client.py index e61f68a..6a2051e 100644 --- a/dnfs/log_client.py +++ b/dnfs/log_client.py @@ -1,160 +1,4 @@ -""" -统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 - -设计要点: - - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 - -用法(与旧版完全兼容): - from log_client import logs - - logs.info("dnfs", "节点新增成功 id=42") - logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 -_FLUSH_INTERVAL = 2.0 -_TIMEOUT = 5 -_QUEUE_MAXSIZE = 10000 - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() - - -class LogClient: - """异步批量日志推送客户端(API 与旧版完全兼容)。""" - - def __init__(self, service_name: str = "log", - batch_size: int = _BATCH_SIZE, - flush_interval: float = _FLUSH_INTERVAL): - self._service_name = service_name - self._client = RpcClient( - _DBV2_ADDR, _DBV2_PORT, - service_name + "-log", - timeout=_TIMEOUT, - ) - self._queue = AsyncRpcQueue( - handler=self._send_batch, - batch_size=batch_size, - flush_interval=flush_interval, - max_queue_size=_QUEUE_MAXSIZE, - on_error=self._on_error, - ) - self._queue.start() - atexit.register(self.close) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service: str, level: str, message: str, - trace_id: str = None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - entry = { - 'ts': _now_iso(), - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - self._queue.put(entry) - - def debug(self, service: str, message: str, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service: str, message: str, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def warn(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service: str, message: str, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service: str, message: str, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志。""" - self._queue.flush() - - def close(self): - """关闭客户端,flush 剩余日志。""" - self._queue.close() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _send_batch(self, batch: list): - """后台线程回调:逐条 INSERT 到 db(与旧版行为一致:网络错误放弃本批剩余)。""" - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - self._client.call("execute", payload, timeout=_TIMEOUT) - except RpcError as e: - print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) - except Exception as e: - print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) - break - - def _on_error(self, e: Exception, batch: list): - """异常安全网:handler 外的异常也不会崩溃线程。""" - print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - - -# ── 全局单例 ────────────────────────────────────────────────────────── -# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 -# 此文件(confs/log_client.py)的 service_name 为 "confs"。 +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 logs = LogClient(service_name="dnfs") diff --git a/gateway/Makefile b/gateway/Makefile index 2bad387..08f0b81 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -3,7 +3,7 @@ APP_NAME := gateway INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ -I ../libmsg/inc -MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg +MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg -lus_logc LIBS := -pthread -lcurl -lssl -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd) CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build @@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR)) all: $(BUILD_DIR)/$(APP_NAME).out $(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ -$(BUILD_DIR)/main.o: src/main.cpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp +$(BUILD_DIR)/main.o: src/main.cpp inc/Conf.hpp inc/ExpBase.hpp $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ clear: @rm -rvf build/ diff --git a/gateway/inc/LogC.hpp b/gateway/inc/LogC.hpp deleted file mode 100644 index a77d23d..0000000 --- a/gateway/inc/LogC.hpp +++ /dev/null @@ -1,235 +0,0 @@ -#pragma once - -/** LogC — gateway 统一日志客户端(C++ 版,对应各 Python 服务的 log_client.py) - * - * 设计要点(与 Python 版对齐): - * - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - * - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送,不拖慢业务 - * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 - * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - * - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长 - * - * 用法: - * #include "LogC.hpp" - * - * qing::LogC::instance().info("gateway", "启动完成"); - * qing::LogC::instance().error("gateway", "数据库连接失败", - * std::nullopt, R"({"errno":2002})"); - * - * // 进程退出前: - * qing::LogC::instance().shutdown(); // 停止后台线程并 flush 剩余日志(幂等) - * - * // 设置日志级别(main 启动时调用,低于此级别的日志直接丢弃) - * qing::LogC::instance().set_level("warn"); // 只输出 warn/error - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace qing { - -namespace json = boost::json; - -class LogC { -public: - struct Entry { - std::string service; - std::string level; - std::string message; - std::optional trace_id; - std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) - }; - - static LogC& instance() { - static LogC inst; - return inst; - } - - // ---- 公开接口 -------------------------------------------------------- - - // 日志级别 → 数值(大写兼容) - static const std::unordered_map& level_order() { - static const std::unordered_map k = { - {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, - {"error", 3}, {"critical", 3}, - {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, - {"ERROR", 3}, {"CRITICAL", 3}, - }; - return k; - } - - // 设置日志级别阈值(debug|info|warn|error,大小写不限) - // 低于此级别的日志在 push 时直接丢弃 - void set_level(const std::string& level) { - auto it = level_order().find(level); - if (it != level_order().end()) { - _level_threshold.store(it->second, std::memory_order_relaxed); - } - } - - // 推送一条日志(非阻塞,立即返回) - void push(const std::string& service, const std::string& level, - const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - // 日志级别过滤:低于阈值直接丢弃 - auto it = level_order().find(level); - int lvl = (it != level_order().end()) ? it->second : 1; - if (lvl < _level_threshold.load(std::memory_order_relaxed)) return; - _queue.push(Entry{service, level, message, - std::move(trace_id), std::move(extra)}); - } - - void debug(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); - } - void info(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "INFO", message, std::move(trace_id), std::move(extra)); - } - void warning(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "WARNING", message, std::move(trace_id), std::move(extra)); - } - void error(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "ERROR", message, std::move(trace_id), std::move(extra)); - } - void critical(const std::string& service, const std::string& message, - std::optional trace_id = std::nullopt, - std::optional extra = std::nullopt) { - push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); - } - - // 手动 flush:通知后台线程立即发送当前积攒的批次,并等待其完成 - void flush() { - _queue.flush(); - } - - // 停止后台线程并 flush 剩余日志(进程退出前调用,幂等) - void shutdown() { - _queue.shutdown(); - } - - // ---- 配置(构造后、首次推送前可改)------------------------------------ - void set_endpoint(const std::string& addr, int port) { - std::lock_guard lock(_endpoint_mtx); - _db_addr = addr; - _db_port = port; - } - -private: - LogC() - : _queue( - [this](std::vector&& batch) { this->_send_batch(std::move(batch)); }, - {_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; - }) - { - _queue.start(); - } - - ~LogC() { shutdown(); } - - LogC(const LogC&) = delete; - LogC& operator=(const LogC&) = delete; - - // 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表 - void _send_batch(const std::vector& batch) { - std::string addr; - int port; - { - std::lock_guard lock(_endpoint_mtx); - addr = _db_addr; - port = _db_port; - } - - const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版) - try { - pmc::net::MsgClient client(addr, port, "gateway-log"); - for (const auto& e : batch) { - json::object values; - values["ts"] = now; - values["service"] = e.service; - values["level"] = e.level; - values["message"] = e.message; - values["trace_id"] = e.trace_id ? json::value(*e.trace_id) - : json::value(nullptr); - values["extra"] = e.extra ? json::value(*e.extra) - : json::value(nullptr); - - json::object payload; - payload["_route"] = "execute"; - payload["action"] = "insert"; - payload["table"] = "Logs"; - payload["values"] = std::move(values); - - const std::string resp = client.call( - json::serialize(payload), _TIMEOUT, - /*retry_on_refused=*/false); // 日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞重试 - auto obj = json::parse(resp).as_object(); - if (!obj.at("success").as_bool()) { - std::string err = "unknown"; - if (auto* ev = obj.if_contains("error")) { - if (ev->is_string()) err = ev->as_string().c_str(); - } - std::cerr << "[LogC] INSERT 失败: " << err << std::endl; - } - } - } catch (const std::exception& e) { - // fail-safe:db 不可用时静默丢弃,绝不影响业务 - std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; - } - } - - // UTC ISO8601 时间戳(对齐 Python datetime.now(timezone.utc).isoformat()) - // 形如 2026-07-29T08:30:00.123456+00:00,字典序即时间序,便于 db 按 ts 清理 - static std::string _utc_now_iso() { - const auto now = std::chrono::system_clock::now(); - const auto t = std::chrono::system_clock::to_time_t(now); - const auto us = std::chrono::duration_cast( - now.time_since_epoch()).count() % 1000000; - std::tm tm{}; - ::gmtime_r(&t, &tm); - char buf[40]; - std::snprintf(buf, sizeof buf, - "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", - tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, - tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); - return buf; - } - - // ---- 配置项 ----------------------------------------------------------- - std::string _db_addr = "127.0.0.1"; - int _db_port = 8005; - std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发) - std::atomic _level_threshold{1}; // 日志级别阈值,默认 INFO(1) - - static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush - static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush - static constexpr size_t _MAX_QUEUE_SIZE = 10000; // 队列上限,满时丢最旧 - static constexpr double _TIMEOUT = 5.0; // socket 超时(秒) - - // ---- 核心队列 --------------------------------------------------------- - pmc::net::AsyncQueue _queue; -}; - -} // namespace qing diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 3fd2bc9..685042e 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -14,7 +14,8 @@ #include #include "ExpBase.hpp" #include "Conf.hpp" -#include "LogC.hpp" +#include +#include using namespace pmc::net; using namespace qing; @@ -319,6 +320,7 @@ int main(int argc, char **argv) { /* 初始化日志推送端点 */ logs().set_endpoint(_DB_HOST, _DB_PORT); + logs().set_service_name("gateway-log"); logs().set_level(_LOG_LEVEL); std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT diff --git a/liblogc/.gitignore b/liblogc/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/liblogc/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/liblogc/LICENSE b/liblogc/LICENSE new file mode 100644 index 0000000..d63a519 --- /dev/null +++ b/liblogc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 建昌风物大顺脚本 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/liblogc/Makefile b/liblogc/Makefile new file mode 100644 index 0000000..f18e91b --- /dev/null +++ b/liblogc/Makefile @@ -0,0 +1,53 @@ +# liblogc — 统一集中式日志客户端库 +# +# 编译产物:libus_logc.so +# 依赖:libus_msg(libmsg)、boost_json +# 安装:头文件 → /usr/include/us/LogC.hpp +# 库文件 → /lib/libus_logc.so + +CC := g++ +STD := -std=c++20 + +VERSION := 0.0.1 +FRAMEWORK_NAME := us +LIB_NAME := libus_logc +INSTL_TDIR := /lib +INCLU_TDIR := /usr/include + +CFLAGS := $(STD) -O2 -fPIC -Wall +LDFLAGS := -shared -lus_msg -lboost_json + +SRC_DIR := src +BUILD_DIR := build +INC_DIR := inc + +SRC := $(wildcard $(SRC_DIR)/*.cpp) +OBJ := $(SRC:$(SRC_DIR)/%.cpp=$(BUILD_DIR)/%.o) + +.PHONY: clear all install uninstall + +all: $(BUILD_DIR) $(BUILD_DIR)/$(LIB_NAME).so + +install: $(BUILD_DIR)/$(LIB_NAME).so + mkdir -p $(INCLU_TDIR)/$(FRAMEWORK_NAME) + cp -r $(INC_DIR)/$(FRAMEWORK_NAME)/* $(INCLU_TDIR)/$(FRAMEWORK_NAME) + cp $(BUILD_DIR)/$(LIB_NAME).so $(INSTL_TDIR)/$(LIB_NAME).so.$(VERSION) + ln -sf $(LIB_NAME).so.$(VERSION) $(INSTL_TDIR)/$(LIB_NAME).so + ldconfig + +uninstall: + rm -f $(INCLU_TDIR)/$(FRAMEWORK_NAME)/LogC.hpp + rm -f $(INSTL_TDIR)/$(LIB_NAME).so + rm -f $(INSTL_TDIR)/$(LIB_NAME).so.$(VERSION) + +clear: + rm -rf $(BUILD_DIR) + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +$(BUILD_DIR)/$(LIB_NAME).so: $(OBJ) + $(CC) $(OBJ) -o $@ $(LDFLAGS) + +$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp + $(CC) $(CFLAGS) -I$(INC_DIR) -c $< -o $@ diff --git a/liblogc/_set.sh b/liblogc/_set.sh new file mode 100755 index 0000000..312f67c --- /dev/null +++ b/liblogc/_set.sh @@ -0,0 +1,5 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +make clear +make +make install diff --git a/liblogc/inc/us/LogC.hpp b/liblogc/inc/us/LogC.hpp new file mode 100644 index 0000000..269f6a6 --- /dev/null +++ b/liblogc/inc/us/LogC.hpp @@ -0,0 +1,144 @@ +#pragma once + +/** LogC — 统一集中式日志客户端(C++ 版) + * + * 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。 + * + * 设计要点: + * - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送 + * - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务 + * - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + * - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长 + * - 级别过滤:set_level() 设定阈值,低于阈值的日志直接丢弃 + * - 可配置 endpoint / service_name,各服务复用同一份库 + * + * 用法: + * #include + * + * // 启动时配置(默认 127.0.0.1:8005) + * qing::LogC::instance().set_endpoint("127.0.0.1", 8005); + * qing::LogC::instance().set_service_name("gateway-log"); + * qing::LogC::instance().set_level("warn"); + * + * qing::LogC::instance().info("gateway", "启动完成"); + * qing::LogC::instance().error("gateway", "连接失败", + * std::nullopt, R"({"errno":111})"); + * + * // 进程退出前调用(幂等) + * qing::LogC::instance().shutdown(); + */ + +#include +#include +#include +#include +#include +#include +#include + +// forward-declare: 避免在头文件中拉入 AsyncQueue 模板 +namespace pmc { namespace net { +template class AsyncQueue; +}} + +namespace qing { + +class LogC { +public: + struct Entry { + std::string service; + std::string level; + std::string message; + std::optional trace_id; + std::optional extra; // 已序列化的 JSON 字符串(存 Logs.extra TEXT) + }; + + // 日志级别 → 数值(大写兼容) + static const std::unordered_map& level_order() { + static const std::unordered_map k = { + {"debug", 0}, {"info", 1}, {"warn", 2}, {"warning", 2}, + {"error", 3}, {"critical", 3}, + {"DEBUG", 0}, {"INFO", 1}, {"WARN", 2}, {"WARNING", 2}, + {"ERROR", 3}, {"CRITICAL", 3}, + }; + return k; + } + + static LogC& instance() { + static LogC inst; + return inst; + } + + // ---- 配置(启动时调用,线程安全)------------------------------------- + + void set_endpoint(const std::string& addr, int port); + void set_service_name(const std::string& name); + + // 设置日志级别阈值(debug|info|warn|error,大小写不限) + void set_level(const std::string& level); + + // ---- 推送接口(非阻塞,立即返回)------------------------------------- + + void push(const std::string& service, const std::string& level, + const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt); + + void debug(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "DEBUG", message, std::move(trace_id), std::move(extra)); + } + void info(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "INFO", message, std::move(trace_id), std::move(extra)); + } + void warning(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "WARNING", message, std::move(trace_id), std::move(extra)); + } + void error(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "ERROR", message, std::move(trace_id), std::move(extra)); + } + void critical(const std::string& service, const std::string& message, + std::optional trace_id = std::nullopt, + std::optional extra = std::nullopt) { + push(service, "CRITICAL", message, std::move(trace_id), std::move(extra)); + } + + // ---- 生命周期 ------------------------------------------------------- + + void flush(); // 手动 flush 当前积攒批次,等待完成 + void shutdown(); // 停止后台线程并 flush 剩余日志(幂等) + +private: + LogC(); + ~LogC(); + + LogC(const LogC&) = delete; + LogC& operator=(const LogC&) = delete; + + void _send_batch(std::vector&& batch); + static std::string _utc_now_iso(); + + // ---- 配置项 --------------------------------------------------------- + std::string _db_addr = "127.0.0.1"; + int _db_port = 8005; + std::string _service_name = "log"; + std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port/_service_name + std::atomic _level_threshold{1}; // 默认 INFO(1) + + static constexpr size_t _BATCH_SIZE = 50; + static constexpr double _FLUSH_INTERVAL = 2.0; + static constexpr size_t _MAX_QUEUE_SIZE = 10000; + static constexpr double _TIMEOUT = 5.0; + + // ---- 核心队列(unique_ptr 延迟到 .cpp 中实例化,保持头文件轻量)---- + std::unique_ptr> _queue; +}; + +} // namespace qing diff --git a/liblogc/src/LogC.cpp b/liblogc/src/LogC.cpp new file mode 100644 index 0000000..2efd147 --- /dev/null +++ b/liblogc/src/LogC.cpp @@ -0,0 +1,141 @@ +/** LogC.cpp — 统一集中式日志客户端实现 + * + * 包含 _send_batch(pymsg → db execute INSERT)和 _utc_now_iso(ISO 8601)。 + * 编译为 libus_logc.so,运行时依赖 libus_msg.so。 + */ + +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace json = boost::json; + +namespace qing { + +// ---- 生命周期 --------------------------------------------------------------- + +LogC::LogC() + : _queue(std::make_unique>( + [this](std::vector&& batch) { this->_send_batch(std::move(batch)); }, + pmc::net::AsyncQueueConfig{_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5}, + [](const std::exception& e, std::vector&&) { + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + })) +{ + _queue->start(); +} + +LogC::~LogC() { shutdown(); } + +// ---- 配置接口 --------------------------------------------------------------- + +void LogC::set_endpoint(const std::string& addr, int port) { + std::lock_guard lock(_endpoint_mtx); + _db_addr = addr; + _db_port = port; +} + +void LogC::set_service_name(const std::string& name) { + std::lock_guard lock(_endpoint_mtx); + _service_name = name; +} + +void LogC::set_level(const std::string& level) { + auto it = level_order().find(level); + if (it != level_order().end()) { + _level_threshold.store(it->second, std::memory_order_relaxed); + } +} + +// ---- 推送 ------------------------------------------------------------------- + +void LogC::push(const std::string& service, const std::string& level, + const std::string& message, + std::optional trace_id, + std::optional extra) { + auto it = level_order().find(level); + int lvl = (it != level_order().end()) ? it->second : 1; + if (lvl < _level_threshold.load(std::memory_order_relaxed)) return; + _queue->push(Entry{service, level, message, + std::move(trace_id), std::move(extra)}); +} + +void LogC::flush() { _queue->flush(); } +void LogC::shutdown() { _queue->shutdown(); } + +// ---- 内部实现 --------------------------------------------------------------- + +void LogC::_send_batch(std::vector&& batch) { + std::string addr; + int port; + std::string client_name; + { + std::lock_guard lock(_endpoint_mtx); + addr = _db_addr; + port = _db_port; + client_name = _service_name; + } + + const std::string now = _utc_now_iso(); + try { + pmc::net::MsgClient client(addr, port, client_name); + for (const auto& e : batch) { + json::object values; + values["ts"] = now; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + values["trace_id"] = e.trace_id ? json::value(*e.trace_id) + : json::value(nullptr); + values["extra"] = e.extra ? json::value(*e.extra) + : json::value(nullptr); + + json::object payload; + payload["_route"] = "execute"; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = std::move(values); + + const std::string resp = client.call( + json::serialize(payload), _TIMEOUT, + /*retry_on_refused=*/false); + auto obj = json::parse(resp).as_object(); + if (!obj.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = obj.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[LogC] INSERT 失败: " << err << std::endl; + } + } + } catch (const std::exception& e) { + // fail-safe:db 不可用时静默丢弃,绝不影响业务 + std::cerr << "[LogC] 发送失败: " << e.what() << std::endl; + } +} + +std::string LogC::_utc_now_iso() { + const auto now = std::chrono::system_clock::now(); + const auto t = std::chrono::system_clock::to_time_t(now); + const auto us = std::chrono::duration_cast( + now.time_since_epoch()).count() % 1000000; + std::tm tm{}; + ::gmtime_r(&t, &tm); + char buf[40]; + std::snprintf(buf, sizeof buf, + "%04d-%02d-%02dT%02d:%02d:%02d.%06d+00:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, static_cast(us)); + return buf; +} + +} // namespace qing diff --git a/pymsg/pymsg/__init__.py b/pymsg/pymsg/__init__.py index 5438a4f..e2bab70 100644 --- a/pymsg/pymsg/__init__.py +++ b/pymsg/pymsg/__init__.py @@ -621,5 +621,7 @@ class MsgServer: # ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)── from .rpc import RpcClient, RpcError, RpcServer # noqa: E402 from .async_queue import AsyncRpcQueue # noqa: E402 +from .logc import LogClient, set_log_level # noqa: E402 -__all__ += ["RpcClient", "RpcServer", "RpcError", "AsyncRpcQueue"] +__all__ += ["RpcClient", "RpcServer", "RpcError", "AsyncRpcQueue", + "LogClient", "set_log_level"] diff --git a/pymsg/pymsg/logc.py b/pymsg/pymsg/logc.py new file mode 100644 index 0000000..397a7a3 --- /dev/null +++ b/pymsg/pymsg/logc.py @@ -0,0 +1,189 @@ +""" +pymsg.logc — 统一集中式日志客户端(Python 版) + +通过 pymsg RPC 调用 db execute 路由,逐条 INSERT 到 Logs 表。 + +设计要点: + - 异步非阻塞:基于 pymsg.AsyncRpcQueue 内存队列 + 后台线程攒批推送 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 + - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + - 背压保护:队列上限 10000 条,满时丢最旧日志,防止 OOM + - 日志级别过滤:set_level() 设定阈值,低于阈值的日志直接丢弃 + - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 + +用法: + from pymsg.logc import LogClient, set_log_level + + logs = LogClient(service_name="redirect80") + logs.info("redirect80", "服务已启动") + logs.error("redirect80", "端口绑定失败", extra={"errno": 98}) + + # 设置日志级别(main 启动时调用) + set_log_level("warn") # 只输出 warn/error/critical +""" + +import atexit +import json +import os +import sys +from datetime import datetime, timezone + +from pymsg import AsyncRpcQueue, RpcClient, RpcError + +# ── 配置 ────────────────────────────────────────────────────────────────────── + +_DBV2_ADDR = os.environ.get("DB_HOST", "127.0.0.1") +_DBV2_PORT = int(os.environ.get("DB_PORT", "8005")) + +_BATCH_SIZE = 50 +_FLUSH_INTERVAL = 2.0 +_TIMEOUT = 5 +_QUEUE_MAXSIZE = 10000 + +# ── 日志级别过滤 ────────────────────────────────────────────────────────────── + +_LEVEL_ORDER = { + "DEBUG": 0, "INFO": 1, + "WARNING": 2, "WARN": 2, + "ERROR": 3, "CRITICAL": 3, +} +_level_threshold = 1 # 默认 INFO + + +def set_log_level(level: str): + """设置全局日志级别阈值。低于此级别的日志直接丢弃。""" + global _level_threshold + lv = level.upper() + if lv == "WARNING": + lv = "WARN" + _level_threshold = _LEVEL_ORDER.get(lv, 1) + + +# ── 工具函数 ────────────────────────────────────────────────────────────────── + +def _safe_json(obj): + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + return repr(obj) + + +def _now_iso(): + """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" + return datetime.now(timezone.utc).isoformat() + + +# ── LogClient 类 ────────────────────────────────────────────────────────────── + +class LogClient: + """异步批量日志推送客户端。 + + API 与 redirect80/log_client.py、confs/log_client.py 等保持一致, + 各服务可直接替换为 ``from pymsg.logc import LogClient``。 + """ + + def __init__(self, service_name: str = "log", + batch_size: int = _BATCH_SIZE, + flush_interval: float = _FLUSH_INTERVAL): + self._service_name = service_name + self._client = RpcClient( + _DBV2_ADDR, _DBV2_PORT, + service_name + "-log", + timeout=_TIMEOUT, + ) + self._queue = AsyncRpcQueue( + handler=self._send_batch, + batch_size=batch_size, + flush_interval=flush_interval, + max_queue_size=_QUEUE_MAXSIZE, + on_error=self._on_error, + ) + self._queue.start() + atexit.register(self.close) + + # ── 公开接口 ────────────────────────────────────────────────────────── + + def set_log_level(self, level: str): + """设置日志过滤阈值(委托给模块级 set_log_level)。""" + set_log_level(level) + + def push(self, service: str, level: str, message: str, + trace_id: str = None, extra=None): + """推送一条日志(非阻塞,立即返回)。 + + 低于设定级别的日志直接丢弃。 + 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程。 + """ + if _LEVEL_ORDER.get(level.upper(), 1) < _level_threshold: + return + entry = { + "ts": _now_iso(), + "service": service, + "level": level, + "message": message, + "trace_id": trace_id, + "extra": extra, + } + self._queue.put(entry) + + def debug(self, service: str, message: str, **kw): + self.push(service, "DEBUG", message, **kw) + + def info(self, service: str, message: str, **kw): + self.push(service, "INFO", message, **kw) + + def warning(self, service: str, message: str, **kw): + self.push(service, "WARNING", message, **kw) + + def warn(self, service: str, message: str, **kw): + self.push(service, "WARNING", message, **kw) + + def error(self, service: str, message: str, **kw): + self.push(service, "ERROR", message, **kw) + + def critical(self, service: str, message: str, **kw): + self.push(service, "CRITICAL", message, **kw) + + def flush(self): + """手动 flush 队列中剩余的日志。""" + self._queue.flush() + + def close(self): + """关闭客户端,flush 剩余日志。""" + self._queue.close() + + # ── 内部实现 ────────────────────────────────────────────────────────── + + def _send_batch(self, batch: list): + """后台线程回调:逐条 INSERT 到 db(网络错误放弃本批剩余)。""" + for entry in batch: + extra_val = entry.get("extra") + try: + extra_json = _safe_json(extra_val) if extra_val is not None else None + except Exception: + extra_json = None + payload = { + "action": "insert", + "table": "Logs", + "values": { + "ts": entry.get("ts", _now_iso()), + "service": entry.get("service"), + "level": entry.get("level"), + "message": entry.get("message"), + "trace_id": entry.get("trace_id"), + "extra": extra_json, + }, + } + try: + self._client.call("execute", payload, timeout=_TIMEOUT) + except RpcError as e: + print(f"[logc] INSERT 失败: {e}", file=sys.stderr) + except Exception as e: + print(f"[logc] 发送失败(放弃本批剩余): {e}", file=sys.stderr) + break + + def _on_error(self, e: Exception, batch: list): + """异常安全网:handler 外的异常也不会崩溃线程。""" + print(f"[logc] _send_batch 异常(已捕获,线程继续运行): {e}", + file=sys.stderr) diff --git a/redirect80/log_client.py b/redirect80/log_client.py index dc3e905..cf26ca6 100644 --- a/redirect80/log_client.py +++ b/redirect80/log_client.py @@ -1,179 +1,4 @@ -""" -统一日志客户端 — redirect80 服务副本(基于 pymsg.AsyncRpcQueue)。 - -设计要点: - - 通过 pymsg RPC 调用 db execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 - - 日志级别过滤:set_level() 设定阈值,低于阈值的日志直接丢弃 - -用法(与旧版完全兼容): - from log_client import logs - - logs.info("redirect80", "服务已启动") - logs.error("redirect80", "端口绑定失败", extra={"errno": 98}) - logs.push("redirect80", "WARNING", "confs 不可用,使用本地配置", - trace_id="startup", extra={"fallback": "redirect80.ini"}) - - # 设置日志级别(main 启动时调用) - from log_client import set_log_level - set_log_level("warn") # 只输出 warn/error -""" - -import json -import os as _os -import sys as _sys -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 -_FLUSH_INTERVAL = 2.0 -_TIMEOUT = 5 -_QUEUE_MAXSIZE = 10000 - -# ── 日志级别过滤 ──────────────────────────────────────────────────── -_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "WARN": 2, "ERROR": 3, "CRITICAL": 3} -_level_threshold = 1 # 默认 INFO - - -def set_log_level(level: str): - """设置全局日志级别阈值。低于此级别的日志直接丢弃。""" - global _level_threshold - lv = level.upper() - if lv == "WARNING": - lv = "WARN" - _level_threshold = _LEVEL_ORDER.get(lv, 1) - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() - - -class LogClient: - """异步批量日志推送客户端(API 与旧版完全兼容)。""" - - def __init__(self, service_name: str = "log", - batch_size: int = _BATCH_SIZE, - flush_interval: float = _FLUSH_INTERVAL): - self._service_name = service_name - self._client = RpcClient( - _DBV2_ADDR, _DBV2_PORT, - service_name + "-log", - timeout=_TIMEOUT, - ) - self._queue = AsyncRpcQueue( - handler=self._send_batch, - batch_size=batch_size, - flush_interval=flush_interval, - max_queue_size=_QUEUE_MAXSIZE, - on_error=self._on_error, - ) - self._queue.start() - atexit.register(self.close) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service: str, level: str, message: str, - trace_id: str = None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 低于设定级别的日志直接丢弃。 - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - if _LEVEL_ORDER.get(level.upper(), 1) < _level_threshold: - return - entry = { - 'ts': _now_iso(), - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - self._queue.put(entry) - - def debug(self, service: str, message: str, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service: str, message: str, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def warn(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service: str, message: str, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service: str, message: str, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志。""" - self._queue.flush() - - def close(self): - """关闭客户端,flush 剩余日志。""" - self._queue.close() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _send_batch(self, batch: list): - """后台线程回调:逐条 INSERT 到 db(网络错误放弃本批剩余)。""" - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - self._client.call("execute", payload, timeout=_TIMEOUT) - except RpcError as e: - print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) - except Exception as e: - print(f"[log_client] 发送失败(放弃本批剩余): {e}", file=_sys.stderr) - break - - def _on_error(self, e: Exception, batch: list): - """异常安全网:handler 外的异常也不会崩溃线程。""" - print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - - -# ── 全局单例 ────────────────────────────────────────────────────────── +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 logs = LogClient(service_name="redirect80") diff --git a/smtp/log_client.py b/smtp/log_client.py index 0067a14..c464cf2 100644 --- a/smtp/log_client.py +++ b/smtp/log_client.py @@ -1,277 +1,4 @@ -"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 -设计要点: - - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 - -用法: - from log_client import logs - - logs.info("dnfs", "节点新增成功 id=42") - logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import threading -import queue -import time -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) -_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 - -# ── 日志级别过滤 ── -_LOG_LEVELS = { - "debug": 0, "info": 1, "warn": 2, "warning": 2, - "error": 3, "critical": 4, -} -_LOG_LEVEL_THRESHOLD = _LOG_LEVELS["info"] # 默认 info - - -def _normalize_level(level): - """归一化级别名:大写转小写,warning→warn。""" - lv = (level or "info").lower() - if lv == "warning": - lv = "warn" - return lv - - -def set_log_level(level): - """设置日志过滤阈值(低于该级别的日志将被丢弃)。""" - global _LOG_LEVEL_THRESHOLD - lv = _normalize_level(level) - if lv in _LOG_LEVELS: - _LOG_LEVEL_THRESHOLD = _LOG_LEVELS[lv] - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() - - -class LogClient: - """异步批量日志推送客户端。""" - - def __init__(self, service_name="log", - batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): - self._service_name = service_name - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._flush_done = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service, level, message, trace_id=None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - # 级别过滤:低于当前阈值的日志直接丢弃(不进队列) - if _LOG_LEVELS.get(_normalize_level(level), 0) < _LOG_LEVEL_THRESHOLD: - return - entry = { - 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - try: - self._queue.put_nowait(entry) - except queue.Full: - # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) - try: - self._queue.get_nowait() - except queue.Empty: - pass - try: - self._queue.put_nowait(entry) - except queue.Full: - pass # 极端竞争下仍失败则静默丢弃 - - def debug(self, service, message, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service, message, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service, message, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service, message, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service, message, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def set_log_level(self, level): - """设置日志过滤阈值(低于该级别的日志将被丢弃)。 - - 委托给模块级 set_log_level,统一通过 logs 单例对外暴露接口。 - """ - set_log_level(level) - - def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_done.clear() - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) - self._flush_done.wait(timeout=self._flush_interval + 2.0) - # 主线程排空队列中剩余条目 - self._drain_queue_to_db() - - def _drain_queue_to_db(self): - """排空主线程可见的队列中剩余条目,直接发送到 db。""" - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 - - 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) - 都不会死亡,避免日志队列无限增长导致 OOM。 - """ - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - try: - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - try: - self._send(batch) - except Exception as e: - # _send 内部已有细粒度异常处理,此处是最终安全网 - print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - batch = [] - last_flush = time.monotonic() - if self._flush_signal.is_set(): - self._flush_signal.clear() - self._flush_done.set() - except Exception as e: - # 最外层安全网:任何意外异常都不应杀死日志线程 - print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - time.sleep(0.5) # 防止异常风暴导致 CPU 满载 - - # 退出前 flush 剩余 - if batch: - try: - self._send(batch) - except Exception as e: - print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) - self._flush_done.set() - - def _send(self, batch): - """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 - - 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), - 不抛异常给调用方。 - """ - try: - client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - except Exception as e: - print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) - return - - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - client.call("execute", payload, timeout=_TIMEOUT) - except RpcError as e: - # 业务错误(如表结构不符):打印后继续下一条 - print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) - except Exception as e: - # 网络错误:db 不可用,放弃本批剩余(fail-safe) - print(f"[log_client] 发送失败: {e}", file=_sys.stderr) - break - - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() - - -# ── 全局单例 ────────────────────────────────────────────────────────── - -logs = LogClient() +logs = LogClient(service_name="smtp") diff --git a/smtp3/Makefile b/smtp3/Makefile index d729726..fd281e1 100644 --- a/smtp3/Makefile +++ b/smtp3/Makefile @@ -1,7 +1,7 @@ CXX := g++ CXXFLAGS := -std=c++20 -Wall -Wextra -O2 INCS := -I./inc -LIBS := -lus_msg -lboost_json -lboost_program_options -lssl -lcrypto -lpthread +LIBS := -lus_msg -lus_logc -lboost_json -lboost_program_options -lssl -lcrypto -lpthread TARGET := smtp3.bin SRC := src/main.cpp diff --git a/smtp3/inc/LogC.hpp b/smtp3/inc/LogC.hpp deleted file mode 100644 index 3e08bba..0000000 --- a/smtp3/inc/LogC.hpp +++ /dev/null @@ -1,124 +0,0 @@ -/* - * LogC.hpp — smtp3 异步日志客户端(简化版) - * - * 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。 - * - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush) - * - fail-safe:db 不可用时静默丢弃 - * - P3-5: 长连接复用(底层 openConnection + pmsg_send/recv), - * 不再每批新建 TCP 连接;连接故障时关闭重建,本批丢弃 - * - API: info() / warning() / error() - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace smtp3 { - -struct LogEntry { - std::string ts; - std::string service; - std::string level; - std::string message; - std::string trace_id; - std::string extra; -}; - -class LogC { -public: - static LogC& instance() { - static LogC inst; - return inst; - } - - void info(const std::string& svc, const std::string& msg) { - push(svc, "INFO", msg); - } - void warning(const std::string& svc, const std::string& msg) { - push(svc, "WARNING", msg); - } - void error(const std::string& svc, const std::string& msg) { - push(svc, "ERROR", msg); - } - -private: - LogC() { - const char* host = std::getenv("DB_HOST"); - const char* port = std::getenv("DB_PORT"); - db_host_ = host ? host : "127.0.0.1"; - db_port_ = port ? std::atoi(port) : 8005; - - queue_ = std::make_unique>( - [this](std::vector&& batch) { send_batch(std::move(batch)); }, - pmc::net::AsyncQueueConfig{.batch_size = 50, .flush_interval = 2.0, .max_queue_size = 10000}, - [](const std::exception& e, std::vector&&) { - std::cerr << "[smtp3/log] send failed: " << e.what() << std::endl; - }); - queue_->start(); - } - - ~LogC() { - if (queue_) queue_->shutdown(); - if (fd_ >= 0) ::close(fd_); - } - - void push(const std::string& svc, const std::string& level, const std::string& msg) { - auto now = std::chrono::system_clock::now(); - auto t = std::chrono::system_clock::to_time_t(now); - char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", std::gmtime(&t)); - - LogEntry e; - e.ts = buf; - e.service = svc; - e.level = level; - e.message = msg; - queue_->push(std::move(e)); - } - - // P3-5: 长连接复用——同一 fd 上逐条 pmsg_send + pmsg_recv(db 对每条 - // execute 都回复,须消费以保持帧同步);任一异常即关闭连接、丢弃本批 - void send_batch(std::vector&& batch) { - try { - if (fd_ < 0) - fd_ = pmc::net::detail::openConnection(db_host_, db_port_, 1.0); - for (auto& e : batch) { - boost::json::object vals; - vals["ts"] = e.ts; - vals["service"] = e.service; - vals["level"] = e.level; - vals["message"] = e.message; - vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id); - vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra); - - boost::json::object req; - req["_route"] = "execute"; - req["action"] = "insert"; - req["table"] = "Logs"; - req["values"] = vals; - - // 与 MsgClient::call 线路格式一致:无前缀,逐条请求-应答 - pmc::net::pmsg_send(fd_, boost::json::serialize(req)); - (void)pmc::net::pmsg_recv(fd_, 1.0); - } - } catch (const std::exception& e) { - if (fd_ >= 0) { ::close(fd_); fd_ = -1; } - std::cerr << "[smtp3/log] batch drop: " << e.what() << std::endl; - } - } - - std::string db_host_; - int db_port_; - std::unique_ptr> queue_; - int fd_ = -1; // 长连接 fd(仅后台 flush 线程访问,无并发) -}; - -} // namespace smtp3 diff --git a/smtp3/src/main.cpp b/smtp3/src/main.cpp index 54a825b..bd59427 100644 --- a/smtp3/src/main.cpp +++ b/smtp3/src/main.cpp @@ -59,12 +59,12 @@ #include #include -#include "LogC.hpp" +#include namespace json = boost::json; namespace po = boost::program_options; -using namespace smtp3; +using qing::LogC; // ═══════════════════════════════════════════════════════════════════════════ // 辅助: 安全的 stoi @@ -716,6 +716,7 @@ static SendLimiter g_limiter; static std::unordered_map g_cooldown; static std::mutex g_cooldown_mtx; static auto& g_log = LogC::instance(); +static const auto _log_init = (LogC::instance().set_service_name("smtp3-log"), true); // ═══════════════════════════════════════════════════════════════════════════ // 8. main() — RpcServer 启动 diff --git a/static/log_client.py b/static/log_client.py index f1afa59..9a97a0a 100644 --- a/static/log_client.py +++ b/static/log_client.py @@ -1,162 +1,4 @@ -""" -统一日志客户端 — 各服务共用(基于 pymsg.AsyncRpcQueue)。 - -设计要点: - - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程攒批推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - - 时间戳:日志入队时即记录 ts(UTC ISO 格式),反映事件真实发生时间 - - 底层复用 pymsg.AsyncRpcQueue 通用队列基础设施 - -用法(与旧版完全兼容): - from log_client import logs - - logs.info("dnfs", "节点新增成功 id=42") - logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import RpcClient, RpcError, AsyncRpcQueue # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') -_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) - -_BATCH_SIZE = 50 -_FLUSH_INTERVAL = 2.0 -_TIMEOUT = 5 -_QUEUE_MAXSIZE = 10000 - - -def _safe_json(obj): - """安全序列化 extra 字段,不可序列化对象转为 repr 字符串。""" - try: - return json.dumps(obj, ensure_ascii=False, default=str) - except Exception: - return repr(obj) - - -def _now_iso(): - """生成当前 UTC 时间 ISO 字符串(入队时调用,保证时间戳准确)。""" - return datetime.now(timezone.utc).isoformat() - - -class LogClient: - """异步批量日志推送客户端(API 与旧版完全兼容)。""" - - def __init__(self, service_name: str = "log", - batch_size: int = _BATCH_SIZE, - flush_interval: float = _FLUSH_INTERVAL): - self._service_name = service_name - self._client = RpcClient( - _DBV2_ADDR, _DBV2_PORT, - service_name + "-log", - timeout=_TIMEOUT, - ) - self._queue = AsyncRpcQueue( - handler=self._send_batch, - batch_size=batch_size, - flush_interval=flush_interval, - max_queue_size=_QUEUE_MAXSIZE, - on_error=self._on_error, - ) - self._queue.start() - atexit.register(self.close) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service: str, level: str, message: str, - trace_id: str = None, extra=None): - """推送一条日志(非阻塞,立即返回)。 - - 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 - """ - entry = { - 'ts': _now_iso(), - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - } - self._queue.put(entry) - - def debug(self, service: str, message: str, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service: str, message: str, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def warn(self, service: str, message: str, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service: str, message: str, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service: str, message: str, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志。""" - self._queue.flush() - - def close(self): - """关闭客户端,flush 剩余日志。""" - self._queue.close() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _send_batch(self, batch: list): - """后台线程回调:逐条 INSERT 到 db。 - - NEW-13:单条失败仅跳过该条,不再因网络异常放弃整批剩余日志。 - RpcError 视为可重试的业务错误(如 db 临时拒绝),继续处理下一条。 - """ - for entry in batch: - extra_val = entry.get("extra") - try: - extra_json = _safe_json(extra_val) if extra_val is not None else None - except Exception: - extra_json = None - payload = { - "action": "insert", - "table": "Logs", - "values": { - "ts": entry.get("ts", _now_iso()), - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": extra_json, - }, - } - try: - self._client.call("execute", payload, timeout=_TIMEOUT) - except Exception as e: - print(f"[log_client] INSERT 失败(跳过本条): {e}", file=_sys.stderr) - continue - - def _on_error(self, e: Exception, batch: list): - """异常安全网:handler 外的异常也不会崩溃线程。""" - print(f"[log_client] _send_batch 异常(已捕获,线程继续运行): {e}", - file=_sys.stderr) - - -# ── 全局单例 ────────────────────────────────────────────────────────── -# 各服务的 log_client.py 副本需将 service_name 改为自己的服务名。 -# 此文件(confs/log_client.py)的 service_name 为 "confs"。 +"""日志客户端 — 委托到 pymsg.logc 统一库。""" +from pymsg.logc import LogClient, set_log_level # noqa: F401 logs = LogClient(service_name="static") From f041bf284b52376cc766c8b4b7fb7dc5b71f006d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 05:01:18 +0000 Subject: [PATCH 034/196] =?UTF-8?q?fix:=20=E9=83=A8=E7=BD=B2=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=A1=A5=E8=A3=85=20liblogc=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20CA=20=E7=AD=89=E6=9C=8D=E5=8A=A1=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _inst.sh 缺少 liblogc 安装步骤,导致生产服务器上 ca/gateway/captcha2/confs2/smtp3/core 链接 libus_logc.so 失败无法启动。 在 libmsg 之后、gateway 之前插入 inst_module liblogc。 --- _inst.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/_inst.sh b/_inst.sh index f85d375..24c8fd8 100755 --- a/_inst.sh +++ b/_inst.sh @@ -30,6 +30,7 @@ inst_module "libhttp" inst_module "libcrypt" inst_module "libsqlite3" inst_module "libmsg" +inst_module "liblogc" inst_module "gateway" inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 waf 再拉起 gateway inst_module "static" From b0d71a4c58c93f8398ea24e172c508fcdceed4fd Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 05:39:00 +0000 Subject: [PATCH 035/196] =?UTF-8?q?core:=20=E7=A7=BB=E9=99=A4=20init.txt?= =?UTF-8?q?=20=E5=90=91=E5=90=8E=E5=85=BC=E5=AE=B9=EF=BC=8C=E4=BB=85?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20services.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InitParser: 移除 parseTxt(),load() 不再回退 init.txt - pmc_mtd: parse_self_init_list 移除 TXT 分支,非 .yaml 文件报错退出 - main.cpp: --help 中 -i 参数描述移除 .txt - Makefile: install/uninstall 移除 init.txt 拷贝/删除 - services.yaml: 头注释移除 init.txt 相关措辞 - 删除 init.txt 文件 - README (core + 项目根): 全部 init.txt 引用替换为 services.yaml - core README: 补全 liblogc/yaml-cpp 依赖声明,修正文件结构 --- README.md | 28 +++++++---------- core/Makefile | 2 -- core/README.md | 49 +++++++++++++++-------------- core/include/registry/InitParser.hh | 16 +++------- core/init.txt | 15 --------- core/services.yaml | 5 ++- core/src/main.cpp | 2 +- core/src/main/pmc_mtd.cpp | 14 ++++----- core/src/registry/InitParser.cpp | 41 +----------------------- 9 files changed, 53 insertions(+), 119 deletions(-) delete mode 100644 core/init.txt diff --git a/README.md b/README.md index 8bd81f8..d8095b1 100755 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ┌──────────────────────────────────────────────┐ │ core (PMC / Nidus) │ │ 进程托管服务 · 监听 :5200 (HTTP 管理接口) │ - │ 读取 init.txt 拉起并守护下列子进程 │ + │ 读取 services.yaml 拉起并守护下列子进程 │ └───────────────┬──────────────────────────────┘ │ 启动 / 守护 ┌──────────────────┬───────────┼───────────┬──────────────────┬──────────────┐ @@ -79,7 +79,7 @@ - 可执行文件 `nidus.out`,作为 systemd 服务(`Nidus.service`)常驻运行(崩溃守护由 systemd 负责,core 自身不含自动重启逻辑)。 - 支持两种工作模式:命令行子系统调用(`--send/--exec/--list/--kill`)与 HTTP 伺服器模式(`--serve `)。 - 通过命名管道(Pipe)与消息队列(Mq)实现进程间通信。 -- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / ca / gateway / captcha / aibox)。 +- 读取 `services.yaml` 声明式配置,按声明顺序拉起各业务服务(db2 / ai_review / dnfs2 / smtp / fsys / waf / ca / gateway / captcha2 / aibox / redirect80)。 ### waf(WAF 网关) @@ -316,7 +316,7 @@ bash _set.sh # 构建 + 安装 bash _run.sh # 运行 ``` -部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。 +部署路径约定为 `/opt/micro_servers/`,`core` 的 `services.yaml` 即从该路径拉起其余服务。 > **注意**:`ai_review` / `aibox` / `redirect80` 已纳入 `_inst.sh`(分别依赖 chromadb ONNX 模型下载、`~/.cert/deepseek.key`、本地 `pymsg` 包)。`ai_review` 额外需配置 `.env` 文件(DeepSeek API 密钥等,参考 [ai_review/README.md](ai_review/README.md))。 @@ -324,20 +324,16 @@ bash _run.sh # 运行 ## 运行 -由 `core` 守护的子进程通过 `init.txt` 自启动(顺序很重要,redirect80 先于 waf、waf 先于 gateway): +由 `core` 守护的子进程通过 `services.yaml` 声明式配置自启动(顺序很重要,db2 先于 ai_review、waf 先于 redirect80)。配置详见 [core/services.yaml](core/services.yaml),摘录如下: -``` -bash /opt/micro_servers/db/_run.sh -bash /opt/micro_servers/confs/_run.sh -bash /opt/micro_servers/ai_review/_run.sh -bash /opt/micro_servers/dnfs/_run.sh -bash /opt/micro_servers/smtp/_run.sh -bash /opt/micro_servers/static/_run.sh -bash /opt/micro_servers/redirect80/_run.sh -bash /opt/micro_servers/waf/_run.sh -bash /opt/micro_servers/gateway/_run.sh -bash /opt/micro_servers/captcha/_run.sh -bash /opt/micro_servers/aibox/_run.sh +```yaml +services: + - name: db2 + cmd: bash /opt/micro_servers/db2/_run.sh + args: ["--instance", "db2", "--log-level", "info"] + depends_on: [] + sleep_before: 0 + # ... 其余 10 个服务(ai_review / dnfs2 / smtp / fsys / waf / ca / gateway / captcha2 / aibox / redirect80) ``` 启动 `core`(需 root 或 systemd): diff --git a/core/Makefile b/core/Makefile index 7c1722e..7d0fd27 100644 --- a/core/Makefile +++ b/core/Makefile @@ -68,7 +68,6 @@ install: $(APP_NAME).out @echo "正在配置 $(APP_NAME)" @mkdir -p $(CONF_DIR) @cp $(CONF_NAME) $(CONF_DIR)/$(CONF_NAME) - @cp init.txt $(CONF_DIR)/init.txt @cp $(SRV_NAME) $(SRV_DIR)/$(SRV_NAME) @systemctl daemon-reload @systemctl enable $(SRV_NAME) @@ -81,7 +80,6 @@ uninstall: @ @rm -f $(INSTL_DIRS)/$(APP_NAME) @rm -f $(CONF_DIRS)/$(CONF_NAME) - @rm -f $(CONF_DIRS)/init.txt @rm -f -d ${CONF_DIRS} @ @systemctl stop $(SRV_NAME) diff --git a/core/README.md b/core/README.md index 2452452..841b0d4 100644 --- a/core/README.md +++ b/core/README.md @@ -5,7 +5,7 @@ ## pmc 是一个什么项目? -PMC(Process Machine,进程机器)是一个可以创建、管理子进程以及模块的进程托管程序。你可以在它的托管下运行独立的业务模块,由 PMC 负责拉起与守护。它是整个 UniServe Framework 的**进程编排中枢**——读取 `init.txt` 自启动列表,顺序拉起并托管全部业务子进程(fsys / dnfs2 / db2 / ca / gateway / waf / captcha2 / smtp / ai_review / aibox / redirect80),并提供本地 HTTP 管理接口与内嵌配置服务。 +PMC(Process Machine,进程机器)是一个可以创建、管理子进程以及模块的进程托管程序。你可以在它的托管下运行独立的业务模块,由 PMC 负责拉起与守护。它是整个 UniServe Framework 的**进程编排中枢**——读取 `services.yaml` 声明式配置,顺序拉起并托管全部业务子进程(fsys / dnfs2 / db2 / ca / gateway / waf / captcha2 / smtp / ai_review / aibox / redirect80),并提供本地 HTTP 管理接口与内嵌配置服务。 崩溃守护由 systemd(`Nidus.service`)负责,PMC 自身**不含**自动重启子进程逻辑。 @@ -18,34 +18,36 @@ PMC(Process Machine,进程机器)是一个可以创建、管理子进程 ``` core/ ├── Makefile # 构建 / 安装(VERSION=0.1.0, APP_NAME=nidus) -├── init.txt # 自启动列表(逐行 bash 命令,# 开头为注释) +├── services.yaml # 声明式服务启动配置(name/cmd/args/env/depends_on/sleep_before) ├── Nidus.service # systemd 单元(User=root, ExecStart=_run.sh) -├── _run.sh # 启动:nidus.out -i init.txt --serve 5200 --confs 8010 +├── _run.sh # 启动:nidus.out -i . --serve 5200 --confs 8010 ├── _set.sh # 构建 + 创建 configs 符号链接 + make install ├── src/ │ ├── main.cpp # 入口:双模式 + 信号 + taskPool 生命周期 -│ └── main/ -│ ├── PPool.cpp # 子进程池 -│ ├── ProcessTask.cpp # boost.process 子进程封装 -│ ├── pmc_mtd.cpp # list / kill / exec / parse_self_init_list -│ ├── subsys_call.cpp # 跨机子系统调用(MQ 发令 + Pipe 回传) -│ ├── Tttask.cpp # 消息队列通信任务 -│ ├── HttpTask.cpp # 管理 HTTP 接口(:5200) -│ └── ConfsTask.cpp # 内嵌配置服务(:8010) +│ ├── main/ +│ │ ├── PPool.cpp # 子进程池 +│ │ ├── ProcessTask.cpp # boost.process 子进程封装 +│ │ ├── pmc_mtd.cpp # list / kill / exec / parse_self_init_list +│ │ ├── subsys_call.cpp # 跨机子系统调用(MQ 发令 + Pipe 回传) +│ │ ├── Tttask.cpp # 消息队列通信任务 +│ │ ├── HttpTask.cpp # 管理 HTTP 接口(:5200) +│ │ └── ConfsTask.cpp # 内嵌配置服务(:8010) +│ └── registry/ +│ └── InitParser.cpp # services.yaml 解析器 └── include/ ├── main/*.hh # PPool / ProcessTask / pmc_mtd / subsys_call / Tttask / HttpTask / ConfsTask + ├── registry/InitParser.hh # services.yaml 解析器 ├── tt/Pipe.h # IPC:命名管道(FIFO) ├── tt/Mq.h # IPC:消息队列 ├── logs/Logger.hpp # 单例日志 - ├── main/ConfsLogC.hpp # 内嵌 confs 异步日志客户端 └── cn/*.hpp # 中文化编程宏 ``` -> 注:早期 README 提到的 `users/`、`extra/` 子模块目录已不再使用,模块托管统一改为通过 `init.txt` 启动列表拉起各业务服务。 +> 注:早期 README 提到的 `users/`、`extra/` 子模块目录已不再使用,模块托管统一改为通过 `services.yaml` 配置拉起各业务服务。 ## 如何托管外部模块 -编辑启动列表 `init.txt`,每行写一条启动命令(以 `#` 开头的行视为注释)。PMC 启动时会逐行拉起(先 `clr()` 清理已退出进程,再 `crtp()` 创建)。**启动顺序很重要**(redirect80 → waf → ca → gateway …)。 +编辑 `services.yaml`,在 `services` 列表中声明每个服务的 `name`、`cmd`、`args`、`env`、`depends_on`、`sleep_before` 等字段。PMC 启动时按声明顺序逐个拉起(先 `clr()` 清理已退出进程,再 `crtp()` 创建)。**启动顺序很重要**(redirect80 → waf → ca → gateway …)。 ## 运行模式与命令行 @@ -59,7 +61,7 @@ PMC 支持两种工作模式: 此外: -- `-i ` / `--init`:指定自启动列表(常用 `init.txt`) +- `-i ` / `--init`:指定自启动配置(目录路径自动查找 `services.yaml`,或直接指定 `.yaml` 文件) - `--confs `:启用内嵌 confs 配置服务(默认 `127.0.0.1:8010`) - `-h` / `--help`:打印帮助与 API MAP(`/list` `/exec` `/kill` 路由) - `-v` / `--version`:打印版本号 @@ -67,7 +69,7 @@ PMC 支持两种工作模式: 实际启动命令(`_run.sh`): ```bash -./nidus.out -i init.txt --serve 5200 --confs 8010 +./nidus.out -i . --serve 5200 --confs 8010 ``` **生命周期**:注册 `SIGINT`/`SIGTERM` → `signalHandler` 唤醒条件变量 `cv` → 主线程 `cv.Wait()` 返回后遍历 `taskPool` 调用 `stop()` 优雅退出。 @@ -90,7 +92,7 @@ PMC 把所有常驻能力抽象为 `ITask`,用 `libth` 的 `Thread`(带 `sto - `list()` → 序列化所有子进程 `{index, pid, running, command}`。 - `kill(pid)` → 按 pid 遍历 `ppool` 匹配后 `kill(i)`。 - `exec(cmd)` → `ppool.crtp(cmd)`。 - - `parse_self_init_list(path)` → 逐行读 `init.txt`,去 `#` 注释与空行后 `clr()` + `crtp(row)`。 + - `parse_self_init_list(path)` → 经 `InitParser` 解析 `services.yaml`,按声明顺序 `clr()` + `crtp()` 拉起,支持 `sleep_before` 延时与 `env` 环境变量注入。 ## 架构:进程间通信层(`include/tt/`) @@ -117,7 +119,7 @@ PMC 把所有常驻能力抽象为 `ITask`,用 `libth` 的 `Thread`(带 `sto core 内置了统一配置服务(由 confs2 合并而来,pymsg RPC 协议,与 Python confs 完全兼容): ```bash -./nidus.out -i init.txt --serve 5200 --confs 8010 +./nidus.out -i . --serve 5200 --confs 8010 ``` - `--confs `:启用内嵌 confs 配置服务(get/list/reload/health 四个路由) @@ -139,7 +141,7 @@ core 内置了统一配置服务(由 confs2 合并而来,pymsg RPC 协议, - **`Logger`**(`logs/Logger.hpp`):单例,控制台输出,支持 DEBUG/INFO/WARNING/ERROR/CRITICAL 级别(注释标注为 DeepSeek 创作,TODO 改为文件存储)。 - **`include/cn/`**:中文化编程宏(`中文化.hpp` 等),提供 C++ 关键字中文别名。 -- **依赖库**(Makefile):`libth` `libhttp`/`libhttpclient` `libmsg` `libcurl` + boost(`program_options`/`json`/`thread`/`filesystem`)+ `ssl`/`crypto`/`pthread`。 +- **依赖库**(Makefile):`libth` `libhttp`/`libhttpclient` `libmsg` `liblogc` `libcurl` + boost(`program_options`/`json`/`thread`/`filesystem`)+ `yaml-cpp` + `ssl`/`crypto`/`pthread`。 ## PMC 的安装方式 @@ -154,6 +156,7 @@ sudo bash _set.sh sudo apt update sudo apt install git g++ make sudo apt install libboost-all-dev +sudo apt install libyaml-cpp-dev #sudo apt install libssl-dev #sudo apt install libcurl4-openssl-dev #sudo apt install libcurl4-gnutls-dev @@ -165,13 +168,13 @@ make # 等价于 make clear && make sudo make install # 装入 /usr/local/bin/nidus + systemd ``` -> `make install` 会把 `init.txt` 拷到 `/etc/Nidus/`、`Nidus.service` 拷到 `/etc/systemd/system`,并 `daemon-reload` + `enable` + `restart`。 +> `make install` 会把 `services.yaml` 拷到 `/etc/Nidus/`、`Nidus.service` 拷到 `/etc/systemd/system`,并 `daemon-reload` + `enable` + `restart`。 本仓库内的快捷方式: ```bash bash core/_set.sh # make clear && make + 建 configs 符号链接 + make install -bash core/_run.sh # 直接 ./nidus.out -i init.txt --serve 5200 --confs 8010 +bash core/_run.sh # 直接 ./nidus.out -i . --serve 5200 --confs 8010 ``` ## 加密通信机制(废弃) @@ -180,7 +183,7 @@ bash core/_run.sh # 直接 ./nidus.out -i init.txt --serve 5200 --confs 8010 ## 如何使用并发机器运行程序 -首先应该在模块中创建一个启动脚本,脚本中应该完成工作目录的切换、虚拟环境的激活,以及程序的启动。PMC 通过 `init.txt` 逐行执行这些启动脚本,从而把多个业务模块拉起并纳入托管。 +首先应该在模块中创建一个启动脚本,脚本中应该完成工作目录的切换、虚拟环境的激活,以及程序的启动。在 `services.yaml` 中声明该脚本后,PMC 会按声明顺序执行这些启动脚本,从而把多个业务模块拉起并纳入托管。 ## 已知待办与风险 @@ -193,6 +196,6 @@ bash core/_run.sh # 直接 ./nidus.out -i init.txt --serve 5200 --confs 8010 ## 上手要点 -- 调整自启动服务/顺序:编辑 `init.txt`(注释用 `#`)。 +- 调整自启动服务/顺序:编辑 `services.yaml`(在 `services` 列表中增删条目,调整声明顺序)。 - 管理接口调用:必须携带 admin token(经 gateway 签发),例如带 `Authorization: Bearer ` 或 `q4_token` Cookie 访问 `:5200`。 - 构建:`core/_set.sh`(会先创建 `configs → ../confs/configs` 符号链接,保证内嵌 confs 零迁移);`make clear && make` 后 `make install` 装入 systemd。 diff --git a/core/include/registry/InitParser.hh b/core/include/registry/InitParser.hh index f40939e..bd15da4 100644 --- a/core/include/registry/InitParser.hh +++ b/core/include/registry/InitParser.hh @@ -8,8 +8,7 @@ namespace qing::registry { /* 服务启动配置项 * - * 从 services.yaml 或 init.txt 解析出的单个服务声明。 - * YAML 模式下包含完整的元数据;TXT 模式下仅 cmd 有值。 + * 从 services.yaml 解析出的单个服务声明,包含完整的元数据。 */ struct ServiceConfig { std::string name; /* 服务唯一标识 */ @@ -25,25 +24,20 @@ struct ServiceConfig { /* 启动配置解析器 * - * 优先读 services.yaml(结构化声明),不存在则回退 init.txt(纯命令列表)。 - * 两种格式都输出 std::vector,调用方无需关心来源。 + * 仅读 services.yaml(结构化声明),输出 std::vector。 */ class InitParser { public: /* 入口:传入配置目录(如 /etc/Nidus) * - * 查找顺序: - * 1. /services.yaml → parseYaml() - * 2. /init.txt → parseTxt() - * 3. 都不存在 → 返回空 vector,调用方自行处理 + * 查找: + * /services.yaml → parseYaml() + * 不存在 → 返回空 vector,调用方自行处理 */ static std::vector load(const std::string& dir); /* 解析 services.yaml */ static std::vector parseYaml(const std::string& path); - - /* 解析 init.txt(向后兼容,每行一条命令,# 开头为注释) */ - static std::vector parseTxt(const std::string& path); }; } // namespace qing::registry diff --git a/core/init.txt b/core/init.txt deleted file mode 100644 index eff798f..0000000 --- a/core/init.txt +++ /dev/null @@ -1,15 +0,0 @@ -bash /opt/micro_servers/db2/_run.sh -bash /opt/micro_servers/ai_review/_run.sh -bash /opt/micro_servers/dnfs2/_run.sh -bash /opt/micro_servers/smtp/_run.sh -bash /opt/micro_servers/fsys/_run.sh -bash /opt/micro_servers/waf/_run.sh -bash /opt/micro_servers/ca/_run.sh -bash /opt/micro_servers/gateway/_run.sh -bash /opt/micro_servers/captcha2/_run.sh -bash /opt/micro_servers/aibox/_run.sh -bash /opt/micro_servers/redirect80/_run.sh - -# -#ssh-copy-id user@your-server -#ssh -D 1080 -N user@your-server diff --git a/core/services.yaml b/core/services.yaml index 2e4bc41..b3299b6 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -1,10 +1,9 @@ # core/services.yaml -# 声明式服务启动配置(替代 init.txt) -# 如果本文件存在则优先使用,否则回退 init.txt +# 声明式服务启动配置 # # 字段说明: # name 服务唯一标识(用于日志和未来注册中心) -# cmd 启动命令(与 init.txt 每行格式一致) +# cmd 启动命令 # args 传递给 _run.sh 的命令行参数列表(可选) # env 启动前设置的环境变量(可选) # depends_on 依赖的服务名列表(未来 DAG 拓扑排序用,当前仅记录) diff --git a/core/src/main.cpp b/core/src/main.cpp index 6500642..c930e64 100644 --- a/core/src/main.cpp +++ b/core/src/main.cpp @@ -149,7 +149,7 @@ int main(int argc, char** argv) { ("list", "list process all run") /* 列出运行中的进程 */ ("kill", po::value(), "kill a process") /* 杀死一个进程 */ /********* 子系统服务模式 ********/ - ("init,i", po::value(), "self-init config (dir or .yaml/.txt file)") /* 输入自启动配置 */ + ("init,i", po::value(), "self-init config (dir or .yaml file)") /* 输入自启动配置 */ ("serve", po::value(), "Run as http server") /* 启用http伺服器 */ ("confs", po::value(), "Run embedded confs rpc server"); /* 启用内嵌confs配置服务 */ diff --git a/core/src/main/pmc_mtd.cpp b/core/src/main/pmc_mtd.cpp index 68a264a..6834da1 100644 --- a/core/src/main/pmc_mtd.cpp +++ b/core/src/main/pmc_mtd.cpp @@ -93,14 +93,11 @@ catch(std::exception& exp) { /* 解析自启动列表、添加自启动项 * - * 兼容两种格式: - * - services.yaml(结构化声明,含 args/env/depends_on/sleep_before) - * - init.txt(纯命令列表,每行一条 bash 命令,# 开头为注释) + * 仅支持 services.yaml 格式(结构化声明,含 args/env/depends_on/sleep_before) * * path 可以是: - * - 目录路径 → 自动查找 services.yaml 或 init.txt + * - 目录路径 → 自动查找目录下的 services.yaml * - .yaml 文件 → 按 YAML 解析 - * - 其他文件 → 按 TXT 解析(向后兼容 init.txt) * * 如果在解析过程中出错,只是打印异常 */ void parse_self_init_list(std::string &path) @@ -110,14 +107,15 @@ try { std::vector services; if (fs::is_directory(path)) { - /* 目录模式:InitParser::load 自动查找 services.yaml → init.txt */ + /* 目录模式:InitParser::load 查找 services.yaml */ services = registry::InitParser::load(path); } else if (path.size() > 5 && path.substr(path.size() - 5) == ".yaml") { /* YAML 文件模式 */ services = registry::InitParser::parseYaml(path); } else { - /* TXT 文件模式(向后兼容 init.txt) */ - services = registry::InitParser::parseTxt(path); + /* 不再支持 init.txt 纯命令列表格式 */ + std::cerr << "parse_self_init_list: 不支持的配置格式,请使用 services.yaml" << std::endl; + return; } if (services.empty()) { diff --git a/core/src/registry/InitParser.cpp b/core/src/registry/InitParser.cpp index 4320fd0..f0970fe 100644 --- a/core/src/registry/InitParser.cpp +++ b/core/src/registry/InitParser.cpp @@ -25,21 +25,13 @@ std::vector InitParser::load(const std::string& dir) { namespace fs = std::filesystem; auto yaml_path = fs::path(dir) / "services.yaml"; - auto txt_path = fs::path(dir) / "init.txt"; - /* 优先 services.yaml */ if (fs::exists(yaml_path)) { std::cerr << "[InitParser] 使用 services.yaml: " << yaml_path << std::endl; return parseYaml(yaml_path.string()); } - /* 回退 init.txt */ - if (fs::exists(txt_path)) { - std::cerr << "[InitParser] 回退 init.txt: " << txt_path << std::endl; - return parseTxt(txt_path.string()); - } - - std::cerr << "[InitParser] 未找到 services.yaml 或 init.txt 于 " << dir << std::endl; + std::cerr << "[InitParser] 未找到 services.yaml 于 " << dir << std::endl; return {}; } @@ -95,37 +87,6 @@ std::vector InitParser::parseYaml(const std::string& path) { return result; } -std::vector InitParser::parseTxt(const std::string& path) { - std::vector result; - std::ifstream file(path); - if (!file.is_open()) { - std::cerr << "[InitParser] 无法打开 init.txt: " << path << std::endl; - return {}; - } - - std::string line; - while (std::getline(file, line)) { - /* 去除注释(# 之后的内容) */ - auto pos = line.find('#'); - if (pos != std::string::npos) - line = line.substr(0, pos); - - /* trim 空白 */ - auto first = line.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) - continue; /* 空行 */ - auto last = line.find_last_not_of(" \t\r\n"); - line = line.substr(first, last - first + 1); - - ServiceConfig svc; - svc.cmd = line; - /* init.txt 模式下 name/args/env/depends_on/sleep_before 均为空/默认 */ - result.push_back(std::move(svc)); - } - - std::cerr << "[InitParser] init.txt 解析完成,共 " << result.size() << " 个服务" << std::endl; - return result; -} } // namespace qing::registry From 936a7586519defbd832b3704931c354cc0f12a76 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 05:51:19 +0000 Subject: [PATCH 036/196] =?UTF-8?q?docs(security):=20core=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=2020260810=20=E5=A4=8D=E5=AE=A1=E5=90=88=E5=85=A5?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复核原 8 项:P0-1 HTTP 鉴权已修复(ca /admin/verify fail-closed),部分修复 4 / 未修复 4 - 新增 8 项发现(P1x2: PPool 无锁竞态、services.yaml 组可写提权路径;P2x4;P3x2) - 更新修复路线图与第二轮代码验证记录 --- docs/security/core_SECURITY_AUDIT_20260801.md | 185 ++++++++++++++---- 1 file changed, 146 insertions(+), 39 deletions(-) diff --git a/docs/security/core_SECURITY_AUDIT_20260801.md b/docs/security/core_SECURITY_AUDIT_20260801.md index f39bc14..4f66226 100644 --- a/docs/security/core_SECURITY_AUDIT_20260801.md +++ b/docs/security/core_SECURITY_AUDIT_20260801.md @@ -4,16 +4,30 @@ **审计范围**: `src/main/`、`src/shared/` **代码版本**: dev_qing 分支 +**复审日期**: 2026-08-10 +**复审范围**: `src/main.cpp`、`src/main/`(含新增 `ConfsTask.cpp`)、`src/registry/InitParser.cpp`、`include/`、`Makefile`、`Nidus.service`、`_run.sh`、`services.yaml` 及运行现场(`/dev/shm`、`/etc/Nidus/`) +**复审结论**: HTTP 管理面 P0 鉴权缺失已修复(ca `/admin/verify` fail-closed 链路);但**部署面新发现 P1 提权路径**(`services.yaml` 组可写 + root 运行)、**PPool 无锁并发竞态**(P1),且 MQ 通道无认证、信号处理、进程池上限、core dump 等旧问题仍开放。详见下文「2026-08-10 复审」章节。 + --- -## 修复进度 +## 修复进度(截至 2026-08-10 复审) + +**原有 8 项问题** | 优先级 | 总数 | 已修复 | 部分修复 | 未修复 | |--------|------|--------|----------|--------| -| P0 | 2 | 0 | 1 | 1 | -| P1 | 3 | 0 | 0 | 3 | -| P2 | 3 | 0 | 1 | 2 | -| **合计** | **8** | **0** | **2** | **6** | +| P0 | 2 | 0 | 2 | 0 | +| P1 | 3 | 0 | 2 | 1 | +| P2 | 3 | 0 | 0 | 3 | +| **合计** | **8** | **0** | **4** | **4** | + +**2026-08-10 复审新增 8 项** + +| 优先级 | 总数 | 编号 | +|--------|------|------| +| P1 | 2 | N1、N2 | +| P2 | 4 | N3、N4、N5、N8 | +| P3 | 2 | N6、N7 | --- @@ -21,28 +35,28 @@ ### P0-1: PMC HTTP 管理接口无认证,可执行任意系统命令 - **位置**: `src/main/HttpTask.cpp` L58、L130-142 -- **当前状态**: ⚠️ 部分修复 +- **当前状态**: ⚠️ 大部分修复(2026-08-10 复审) - ✅ 管理接口已绑定 `127.0.0.1`(`HttpTask.cpp` L58),仅本地可访问 - - ❌ MQ 通道的 `kill`/`exec` 操作**仍在活跃执行**(`main.cpp` L283-296),L282 注释标注"在身份验证之前禁止操作"但代码未实际禁用 - - ❌ **HTTP 端点 `/exec`、`/kill`、`/list` 仍无任何认证**(`HttpTask.cpp` L130-142),本地任意进程可调用 - - ❌ 无操作审计日志 + - ✅ **HTTP 端点 `/list`、`/exec`、`/kill` 已全部接入管理员鉴权**(2026-08-10 确认):`verify_admin_token()`(`HttpTask.cpp` L159-204)调用 ca 的 `/admin/verify`(默认 `127.0.0.1:8443`,`GATEWAY_HOST`/`GATEWAY_PORT` 可配;v0.9.0 拓扑下 8443 由 ca 接管,契约不变)做 RSA 验签 + is_admin 检查,Authorization 头优先、回退 `q4_token` Cookie,ca 不可达/响应异常一律 401(fail-closed) + - ⚠️ MQ 通道的 `kill`/`exec` 操作**仍无认证**(`main.cpp` L320-334),但风险降级:生产部署下 MQ 文件(`/dev/shm/`)为 root 属主 0644,仅 root 可写入发送;残留风险为非 root 运行(开发模式)时同用户进程可注入,且 MQ 操作完全绕过审计 → 见 N1/N3 + - ❌ 无操作审计日志(HTTP 与 MQ 两个通道均未记录) - **修复建议**: 1. ~~确保管理接口仅监听 127.0.0.1~~ ✅ 已实现 2. 添加强认证(预共享 Token 或 Basic Auth over localhost) 3. 危险命令(exec、kill)要求二次确认 4. 记录所有管理操作审计日志 -### P0-2: init.txt 配置文件无完整性校验,可注入恶意命令 -- **位置**: `src/main/pmc_mtd.cpp` L90-118 `parse_self_init_list()` -- **当前状态**: ❌ 未修复 - - 无数字签名/校验和验证 - - 无文件大小限制检查 - - 支持 `#` 注释行跳过(基础防护),但命令行内容无任何校验 - - 逐行读取后直接 `ppool.crtp(row)` 创建进程 +### P0-2: 自启动配置文件无完整性校验,可注入恶意命令 +- **位置**: 原 `src/main/pmc_mtd.cpp` `parse_self_init_list()`;现为 `src/registry/InitParser.cpp`(2026-08-10 复审时已重写) +- **当前状态**: ⚠️ 部分缓解(2026-08-10 复审) + - ✅ init.txt 纯命令列表格式已废弃,改为结构化 `services.yaml`(yaml-cpp 解析,`InitParser::parseYaml()` L38-88),缺 `cmd` 的条目跳过,解析异常捕获不崩溃 + - ❌ 仍无数字签名/校验和验证,`cmd`/`args` 内容无任何校验,`toCommand()` 拼接后直接 `ppool.crtp()` 以 shell 执行 + - ❌ **文件权限未收紧反而恶化**:实际运行时加载的 `/opt/micro_servers/core/services.yaml`(`_run.sh` 以 `-i .` 指定)权限为 `0664 qing:qing` **组可写**,而 core 经 systemd 以 **root** 运行 → qing 组成员篡改后下次启动即以 root 执行任意命令(**提权路径,见 N2**) + - ❌ 部署漂移:`make install` 将 services.yaml 复制到 `/etc/Nidus/`,但 `_run.sh` 实际读取工作目录文件;`/etc/Nidus/init.txt` 为已废弃格式的残留文件 - **修复建议**: - 1. init.txt 添加数字签名/校验和验证 - 2. 运行时监控 init.txt 修改,变更需管理员确认 - 3. 文件权限设为 0600,仅 root/运行用户可写 + 1. ~~init.txt 添加数字签名/校验和验证~~(格式已废弃)→ 对 services.yaml 做校验和/签名 + 2. 运行时监控 services.yaml 修改,变更需管理员确认 + 3. 文件权限设为 0640 root:root,仅 root 可写;统一配置加载路径(`/etc/Nidus/services.yaml`) --- @@ -50,9 +64,9 @@ | # | 问题 | 状态 | 位置 | 说明 | |---|------|------|------|------| -| P1-1 | 子进程异常退出无次数限制 | ❌ 未修复 | `PPool.cpp`、`pmc_mtd.cpp` | `PPool` 无重启计数/频率限制,`crtp()` 无熔断机制;`ProcessTask.cpp` 在 `#ifdef __NEW_VER` 下未启用 | -| P1-2 | 日志路径穿越 | ❌ 未修复 | `pmc_mtd.cpp` L90-118 | init.txt 解析时未校验子进程命令行中的日志路径参数,可被构造写入任意位置 | -| P1-3 | C++ HTTP 解析器需审计缓冲区溢出 | ❌ 未修复 | libhttp 库(`src/shared/`) | 与 gateway P1-4 同一问题,libhttp 库未进行 fuzz 测试 | +| P1-1 | 子进程异常退出无次数限制 | ❌ 未修复(风险性质已变化) | `PPool.cpp`、`ProcessTask.hh` | 2026-08-10 复审:代码中**根本不存在自动重启逻辑**(`ProcessTask` 无重启循环,README 声称的"自动重启"与代码不符),故 crash-loop 风险不存在,但反向缺失守护能力——子进程退出即永久死亡,无重启计数/熔断/告警 | +| P1-2 | 日志路径穿越 | ⚠️ 部分缓解 | `pmc_mtd.cpp` L103-153、`InitParser.cpp` | 2026-08-10 复审:init.txt 解析路径已移除,命令行经 services.yaml 结构化声明后原样透传,core 不再直接解析日志路径参数;残留风险等价于 P0-2(取决于 services.yaml 文件权限,见 N2) | +| P1-3 | C++ HTTP 解析器需审计缓冲区溢出 | ⚠️ 部分覆盖 | libhttp 库 | 2026-08-10 复审:libhttp 已有专项审计(`docs/security/libhttp_SECURITY_AUDIT_20260803.md`),但 core 自身仍未对 HTTP 输入做 fuzz 测试 | --- @@ -60,43 +74,97 @@ | # | 问题 | 状态 | 说明 | |---|------|------|------| -| P2-1 | 信号处理函数中使用非异步安全函数 | ❌ 未修复 | `main.cpp` L76-81 `signalHandler` 调用 `LOG_INFO`(日志宏,底层为 printf)和 `cv.WakeCv()`(条件变量),均非异步信号安全 | -| P2-2 | 线程池任务队列无上限 | ⚠️ 部分修复 | 消息队列 `Mq` 有固定容量(`msgcnt`),但进程池 `PPool` 无大小上限,`crtp()` 可无限追加 | -| P2-3 | 崩溃 core dump 可能包含敏感信息 | ❌ 未修复 | 无 core dump 过滤或禁用机制,崩溃时可能泄露内存中的密码哈希、Token 等 | +| P2-1 | 信号处理函数中使用非异步安全函数 | ❌ 未修复 | `main.cpp` L86-91 `signalHandler` 调用 `LOG_INFO`(日志宏,底层为 printf)和 `cv.WakeCv()`(条件变量),均非异步信号安全(对比:gateway v0.8.0 已改为 `volatile sig_atomic_t` 标志 + `write()` 的正确姿势,可直接参照) | +| P2-2 | 线程池任务队列无上限 | ❌ 未修复 | 消息队列 `Mq` 有固定容量(`msgcnt=100`),但进程池 `PPool` 仍无大小上限,`crtp()`(`PPool.cpp` L7-17)可无限追加 | +| P2-3 | 崩溃 core dump 可能包含敏感信息 | ❌ 未修复 | `Nidus.service` 未设置 `LimitCORE=0`,且以 `User=root` 运行;崩溃时可能泄露内存中的 Token、环境变量等 | + +--- + +## 🆕 2026-08-10 复审新增发现 + +### N1(P1): PPool 进程池无锁,HTTP 与 MQ 双通道并发竞态 +- **位置**: `pmc_mtd.cpp` L18 静态 `ppool` + `PPool.cpp` 全部方法 +- **问题**: HTTP 服务 4 个 worker 线程(`HttpTask.cpp` L58)与 Tttask MQ 回调线程(`main.cpp` L256-263)可同时调用 `pmc_mtd::list/exec/kill`,并发读写 `std::list>`,无任何互斥保护 → 数据竞争/UB,可导致 core(root 进程)崩溃或内存损坏 +- **修复建议**: 为 `PPool` 内部加 `std::mutex`,或在 `pmc_mtd` 命名空间层统一加锁 + +### N2(P1): services.yaml 组可写 + root 运行 = 本地提权路径 +- **现场证据**: `/opt/micro_servers/core/services.yaml` 权限 `0664 qing:qing`;`Nidus.service` 以 `User=root` 运行;`_run.sh` 执行 `nidus.out -i .`(加载仓库目录文件) +- **问题**: qing 组任何成员可篡改 `cmd`/`args`/`env`,core 重启(systemd restart、主机重启或 kill)后以 **root** 执行注入命令 +- **修复建议**: `chown root:root` + `chmod 0640`;或 core 启动时检查配置文件属主/权限,非 root 独占即拒绝加载(fail-closed) + +### N3(P2): `/tmp/pst-*` FIFO 可预测命名 + 0666 权限 +- **位置**: `include/tt/Pipe.h` `mkfifo(fifo_path, 0666)`,路径 `/tmp/pst-` +- **问题**: 名称仅由 pid 构成且位于全局可写 `/tmp`:本地攻击者可预创建同名 FIFO 截获/阻断响应(如进程列表),或以大量写端挂起读方;异常路径下 FIFO 残留(subsys_call 注释已自认) +- **修复建议**: 改用 `mkstemp` 式随机名 + `fchmod 0600`,或迁移到匿名管道/socketpair + +### N4(P2): verify_admin_token 无超时,可被挂起型上游拖死管理面 +- **位置**: `HttpTask.cpp` L187-190 `post_stream_sync` +- **问题**: ca 不可达时快速失败(fail-closed ✅),但 ca 建立连接后挂起时,4 个 worker 线程全部阻塞 → 管理接口拒绝服务(不影响安全边界,仅可用性) +- **修复建议**: HttpClient 设置连接/读取超时(如 2s) + +### N5(P2): setenv 环境变量跨服务泄漏 +- **位置**: `pmc_mtd.cpp` L136-137 `parse_self_init_list()` +- **问题**: 每个服务的 `env` 条目通过 `setenv()` 永久写入 core 自身环境,前序服务的环境变量会被后续所有子进程继承(如 db2 的敏感 env 泄漏到 ai_review 子进程) +- **修复建议**: 改用 `boost::process::env` 或 fork 后子进程内 setenv,不污染父进程 + +### N6(P3): PPool::clr() 空池未定义行为 +- **位置**: `PPool.cpp` L20-32 +- **问题**: 池为空时 `--pool.end()` 为 UB(std::list 实践上通常无害,但属未定义行为);首次启动流程 `parse_self_init_list` 必经此路径 + +### N7(P3): 部署配置漂移与残留文件 +- **现场证据**: `Makefile install` 目标将 services.yaml 复制到 `/etc/Nidus/`,但 `_run.sh` 以 `-i .` 读仓库目录,`/etc/Nidus/services.yaml` 实际不存在,仅残留已废弃格式的 `/etc/Nidus/init.txt`(0644 root) +- **风险**: 低(init.txt 已不再被解析),但易误导运维,且两处配置不一致时行为不可预期 + +### N8(P2): Nidus.service 无安全加固指令 +- **位置**: `Nidus.service` +- **问题**: 仅 `User=root` + `ExecStart`,无 `NoNewPrivileges`、`ProtectSystem`、`ProtectHome`、`LimitCORE=0`、`PrivateTmp` 等加固项;root 运行放大任何本地漏洞的影响面(与 N2/N3 叠加) + +### 复审正面确认(新增安全机制) + +| 机制 | 位置 | 评价 | +|------|------|------| +| 管理员鉴权 fail-closed 链路 | `HttpTask.cpp` L159-204、L280/L315/L368 | ✅ 三路由全覆盖;响应 JSON 字段类型严格校验(`is_bool`);异常/不可达一律拒绝 | +| Token 提取健壮性 | `HttpTask.cpp` L161-174 | ✅ Bearer 前缀校验,Cookie 回退仅读 `q4_token`,空值直接拒绝 | +| 内嵌 ConfsTask 路径安全 | `ConfsTask.cpp` L133-270 | ✅ 配置名白名单(禁 `/`、`\`)+ 扩展名白名单 + `weakly_canonical` 前缀校验 + 锁内 stat/read 无 TOCTOU + 1MiB 大小上限 + list 路由符号链接逃逸检测,与 confs2 审计水准一致 | +| MQ 消息长度限制 | `Mq.h` `send()` | ✅ 超长消息抛 `length_error`,缓冲区按 `msglen` 定长分配 | --- ## ✅ 已有安全机制 -1. ✅ **进程生命周期管理**: 自动重启异常退出的子进程,保障服务可用性 +1. ⚠️ **进程生命周期管理**: 2026-08-10 复审更正——代码中并无自动重启逻辑(见 P1-1),实际仅提供创建/列举/终止能力,README 描述与实现不符 2. ✅ **线程池模型**: 固定线程数,防止无限线程创建 3. ✅ **日志集中管理**: 统一日志格式和输出 4. ✅ **C++ RAII 内存管理**: 使用智能指针减少内存泄漏风险 5. ✅ **依赖审计**: 使用经过审计的 libth 线程库、libhttp HTTP 库、libcrypt 加密库 6. ✅ **管理接口本地绑定**: HTTP 管理接口仅监听 `127.0.0.1`(`HttpTask.cpp` L58) -7. ❌ **MQ 通道危险操作未禁用**: `kill`/`exec` 通过消息队列的路径**仍在活跃执行**(`main.cpp` L283-296),L282 注释标注"在身份验证之前禁止操作"但代码未实际注释禁用 +7. ✅ **HTTP 管理接口管理员鉴权**(2026-08-10 新增): `/list`、`/exec`、`/kill` 均经 ca `/admin/verify` RSA 验签 + is_admin 检查,fail-closed +8. ⚠️ **MQ 通道危险操作仍无认证**: `kill`/`exec` 经消息队列的路径仍活跃(`main.cpp` L320-334);生产部署下受 MQ 文件 0644 root 属主限制仅 root 可写,但绕过审计且非 root 运行时无防护 --- -## 修复路线图 +## 修复路线图(2026-08-10 复审后重排) ### 立即(24小时) - [x] 确认 PMC HTTP 接口仅绑定 127.0.0.1 → ✅ 已确认 -- [ ] **禁用或保护 MQ 通道 kill/exec**(`main.cpp` L283-296 当前仍活跃,注释标注禁止但未实际执行) -- [ ] PMC HTTP 接口 `/exec`、`/kill` 添加认证(当前仅绑定 localhost 不足以防御本地恶意进程) -- [ ] 检查 init.txt 文件权限 +- [x] PMC HTTP 接口添加认证 → ✅ 已实现(ca `/admin/verify` fail-closed) +- [ ] **收紧 services.yaml 权限为 root 属主 0640**(N2,当前 0664 qing 组可写,为现存最高风险项) +- [ ] MQ 通道 kill/exec 添加认证或禁用(`main.cpp` L320-334) +- [ ] PPool 加锁(N1,并发竞态可崩 root 进程) ### 短期(72小时) -- [ ] PMC HTTP 接口添加认证(预共享 Token 或 Basic Auth) -- [ ] 子进程重启频率限制(指数退避或熔断) -- [ ] 审计 libhttp HTTP 解析器 +- [ ] 子进程守护:退出自动重启 + 计数熔断(P1-1,README 声称但代码缺失) +- [ ] `/tmp/pst-*` FIFO 改随机名 + 0600(N3) +- [ ] verify_admin_token 加 HTTP 超时(N4) +- [ ] Nidus.service 加固(LimitCORE=0 / NoNewPrivileges / ProtectSystem,P2-3 + N8) ### 中期 -- [ ] init.txt 完整性校验 -- [ ] 管理操作审计日志 -- [ ] 信号处理函数安全改造(仅使用 `volatile sig_atomic_t` 标志位) -- [ ] 进程池大小上限 -- [ ] Core dump 过滤/禁用 +- [ ] services.yaml 完整性校验(校验和/签名)+ 统一加载路径到 /etc/Nidus/(P0-2 + N7) +- [ ] 管理操作审计日志(HTTP + MQ 双通道) +- [ ] 信号处理函数安全改造(参照 gateway v0.8.0 的 async-signal-safe 实现,P2-1) +- [ ] setenv 跨服务泄漏修复(N5) +- [ ] 进程池大小上限(P2-2);PPool::clr() 空池 UB 修复(N6) +- [ ] core 对 HTTP 输入的 fuzz 测试(P1-3 残留) --- @@ -134,3 +202,42 @@ | MQ 通道危险操作**未禁用** | `main.cpp` L282-296 | `kill`/`exec` 通过消息队列的路径**仍活跃**,L282 注释标注"在身份验证之前禁止操作"但未实际注释代码 | | HTTP 管理接口本地绑定 | `HttpTask.cpp` L58 | 显式绑定 `127.0.0.1`,非 `0.0.0.0` | | init.txt 注释行支持 | `pmc_mtd.cpp` L105-107 | 支持 `#` 注释行跳过,可禁用特定启动项 | + +--- + +## 代码验证记录(第二轮) + +**验证日期**: 2026-08-10 +**验证方式**: 逐行审读全部源码(`main.cpp`、`src/main/` 8 个 .cpp、`src/registry/InitParser.cpp`、`include/` 全部头文件)+ 构建配置(`Makefile`、`_run.sh`、`Nidus.service`)+ 运行现场取证(`/dev/shm` MQ 文件权限、`/etc/Nidus/` 内容、services.yaml 属主权限);交叉验证 ca `/admin/verify` 契约(`ca/src/main.cpp` L1203/L1585) + +### 原有 8 项逐条复核结果 + +| 编号 | 状态变化 | 代码/现场依据 | +|------|----------|----------------| +| P0-1 | ⚠️ 部分 → ⚠️ 大部分修复 | HTTP 三路由全部前置 `verify_admin_token()`(`HttpTask.cpp` L280/L315/L368),fail-closed;MQ 通道(`main.cpp` L320-334)仍无认证但受 `/dev/shm` 文件 0644 root 属主限制(现场取证:`-rw-r--r-- root root`) | +| P0-2 | ❌ 未修复 → ⚠️ 部分缓解 | init.txt 格式废弃,改 `registry/InitParser.cpp` 结构化解析;但无完整性校验且 services.yaml 组可写(0664),提权风险反而具体化(N2) | +| P1-1 | ❌ 未修复(性质变化) | 全部源码检索确认无重启循环;crash-loop 风险不存在,改为守护缺失问题 | +| P1-2 | ❌ → ⚠️ 部分缓解 | init.txt 解析路径删除,命令行透传不经 core 解析;残面收敛到 services.yaml 权限 | +| P1-3 | ❌ → ⚠️ 部分覆盖 | libhttp 已有专项审计(20260803);core 自身 fuzz 仍缺 | +| P2-1 | ❌ 未修复 | `main.cpp` L86-91 原样(行号因代码演进偏移,原报告 L76-81) | +| P2-2 | ⚠️ → ❌ 未修复 | `PPool::crtp`(`PPool.cpp` L7-17)仍无任何上限;原判"部分修复"仅指 Mq 容量,维持原判后 PPool 部分无任何改善 | +| P2-3 | ❌ 未修复 | `Nidus.service` 无 `LimitCORE` 等指令,且确认 root 运行 | + +### 新增 8 项发现 + +| 编号 | 等级 | 状态 | 摘要 | +|------|------|------|------| +| N1 | P1 | ❌ 开放 | PPool 无锁并发竞态(HTTP 4 worker + MQ 线程) | +| N2 | P1 | ❌ 开放 | services.yaml 0664 组可写 + core root 运行 → 本地提权 | +| N3 | P2 | ❌ 开放 | `/tmp/pst-` FIFO 0666 可预测命名 | +| N4 | P2 | ❌ 开放 | verify_admin_token 无超时,挂起型 DoS | +| N5 | P2 | ❌ 开放 | setenv 污染 core 环境,env 跨服务泄漏 | +| N6 | P3 | ❌ 开放 | PPool::clr() 空池 `--end()` UB | +| N7 | P3 | ❌ 开放 | /etc/Nidus/ 配置漂移与 init.txt 残留 | +| N8 | P2 | ❌ 开放 | Nidus.service 无 systemd 安全加固 | + +### 复审综合结论 + +- **修复率(原 8 项)**: 已修复 0 / 部分修复 4 / 未修复 4,较 2026-08-03 验证(部分 2 / 未修复 6)有实质进展,核心驱动力为 HTTP 管理面接入 ca 鉴权链路 +- **新增风险面**: 8 项(P1×2、P2×4、P3×2),其中 **N2(services.yaml 提权路径)为当前最高优先级**,仅需一条 `chown/chmod` 即可消除 +- **安全评级建议**: 网络面(远程攻击面)已收敛良好(loopback + fail-closed 鉴权);本地提权面(同机普通用户 → root)为主要短板,建议修复 N1/N2/N3 后再评定级 From 8b6eb47c8072a0b3ee55d8a6f1c25c3d1f808e2b Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 05:56:54 +0000 Subject: [PATCH 037/196] =?UTF-8?q?docs:=20=E6=9B=B4=E6=AD=A3=20confs=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=86=E5=8F=91=E6=8F=8F=E8=BF=B0=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=83=A8=E7=BD=B2=E5=AE=89=E5=85=A8=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 架构总览配置链路补全消费方清单(ca/gateway/db2/static/redirect80/smtp/captcha) - static 章节更正:confs 为强依赖,本地 config.ini fail-safe 回退已废除 - confs 章节新增配置文件清单与依赖强度表(8 个 ini) - 新增部署安全约定:confs/configs/*.ini 需 root:root 0640(引用 core 审计 20260810 复审) --- README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d8095b1..570d879 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ └────────────┘ 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 - 配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) + 配置链路: ca / gateway / db2 / static / redirect80 / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) ``` > `db2` / `dnfs2` 分别是 `db` / `dnfs` 的 C++20 重实现(接口完全兼容,可平替),架构位置与 Python 版一致。`BotOps` 为 aibox 的终端前端(WebSocket 客户端),`lci` 为嵌入式帧缓冲图形界面,均不在上图中。 @@ -119,7 +119,7 @@ - **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。 - 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。 - 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。 -- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);本地 [static/config.ini](static/config.ini) 作为 confs 不可用时的 fail-safe 回退。 +- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);confs 为**强依赖**,拉取失败直接退出,无本地回退。 ### dnfs(树状菜单服务) @@ -200,7 +200,22 @@ - 集中存放各微服务的配置文件(`confs/configs/`),基于 pymsg(RpcServer,TCP 长度前缀帧 + JSON)分发,监听 127.0.0.1:8010。 - pymsg 路由:`get`(取配置文件全文;带 `key` 时按键提取,ini 用 `Section.option`、json 用点号路径)、`list`(列出全部配置及元信息)、`reload`(重载指定配置,缺省清空全部缓存)、`health`。 - 内存缓存 + 文件 mtime 变化自动重载,改配置无需重启 confs;配置名校验(basename + realpath 前缀 + 扩展名白名单)防路径遍历。 -- 消费方约定:服务启动时用 `RpcClient(8010).call("get", {"name": "<服务名>.ini"})` 拉取配置,confs 不可用时回退本地配置文件(fail-safe)。目前 `static` 已接入。 +- 消费方约定:服务启动时用 `RpcClient(8010).call("get", {"name": "<服务名>.ini"})` 拉取配置。各模块目录不再保留本地 ini(fail-safe 回退已废除)。 + +**配置文件清单与依赖强度**(均位于 `confs/configs/`): + +| 配置文件 | 消费模块 | 依赖强度 | +|----------|----------|----------| +| `ca.ini` | ca | confs 拉取 | +| `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | +| `db.ini` | db / db2 | 强依赖(confs 未就绪自动重试) | +| `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | +| `redirect80.ini` | redirect80 | 强依赖(confs 不可用直接退出) | +| `smtp.ini` | smtp / smtp3 | confs 拉取 | +| `captcha.ini` | captcha / captcha2 | confs 拉取 | +| `example.ini` | —(旧 gateway 示例模板) | 无消费方 | + +- **部署安全约定**:`confs/configs/*.ini` 含敏感字段(`ca.ini` 管理员白名单是 `is_admin` 判定依据、`smtp.ini` 密码字段),权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可直接提权为管理员或劫持邮件验证码(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。 ### aibox(独立 AI 智能体系统) From 9d548c064f7e4d0daab13bf6faee434156c55aab Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 06:13:43 +0000 Subject: [PATCH 038/196] =?UTF-8?q?refactor(ca):=20v1.1.0=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BD=93=E7=B3=BB=E9=87=8D=E6=9E=84=E4=B8=BA=E7=BA=AF?= =?UTF-8?q?=20CLI=20=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 confs 依赖:删 Conf.hpp 与 confs/configs/ca.ini,配置加载改为 CLI > 环境变量 > 默认值三层(与 dnfs2 同模式) - 新增 16 个配置选项,env 以 CA_* 为主并兼容旧名(LISTEN_ADDR/UPSTREAM_GATEWAY/DB_* 等),启动时配置完整性校验 - 原 ca.ini 配置值迁入 core/services.yaml ca 条目 args - 修复伪造 Token 触发 500:verify_token 统一捕获 RSA 验签异常,按验签失败返回 401(符合黑盒 B3/B4 契约) - 同步更新 ca/README.md 与根 README.md --- README.md | 8 +- ca/Makefile | 4 +- ca/README.md | 47 ++++--- ca/_run.sh | 5 +- ca/inc/Conf.hpp | 77 ----------- ca/src/main.cpp | 316 +++++++++++++++++++++++-------------------- confs/configs/ca.ini | 51 ------- core/services.yaml | 38 +++++- 8 files changed, 244 insertions(+), 302 deletions(-) delete mode 100644 ca/inc/Conf.hpp delete mode 100644 confs/configs/ca.ini diff --git a/README.md b/README.md index 570d879..a8550bd 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ └────────────┘ 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 - 配置链路: ca / gateway / db2 / static / redirect80 / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) + 配置链路: gateway / db2 / static / redirect80 / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载;ca/dnfs2 例外,配置纯 CLI 传递) ``` > `db2` / `dnfs2` 分别是 `db` / `dnfs` 的 C++20 重实现(接口完全兼容,可平替),架构位置与 Python 版一致。`BotOps` 为 aibox 的终端前端(WebSocket 客户端),`lci` 为嵌入式帧缓冲图形界面,均不在上图中。 @@ -90,9 +90,10 @@ - 极客操作:`kill -HUP ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。 -### ca(中央认证服务 · v1.0.0) +### ca(中央认证服务 · v1.1.0) - 内部 HTTP 服务,默认监听 127.0.0.1:8443(接管原 gateway 端口,WAF upstream 不变),不再承载 TLS(TLS 已由 WAF 终止)。 +- **配置体系(v1.1.0)**:彻底移除 confs 依赖,配置全部经 CLI 传递(CLI > 环境变量 > 默认值三层),原 `ca.ini` 配置值迁入 [core/services.yaml](core/services.yaml) 的 ca 条目 args;兼容旧环境变量名(`LISTEN_ADDR`/`UPSTREAM_GATEWAY`/`DB_*` 等)。 - **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。 - **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。 - **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效;jti 黑名单 + 用户版本号双轨吊销。 @@ -206,7 +207,6 @@ | 配置文件 | 消费模块 | 依赖强度 | |----------|----------|----------| -| `ca.ini` | ca | confs 拉取 | | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | | `db.ini` | db / db2 | 强依赖(confs 未就绪自动重试) | | `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | @@ -215,7 +215,7 @@ | `captcha.ini` | captcha / captcha2 | confs 拉取 | | `example.ini` | —(旧 gateway 示例模板) | 无消费方 | -- **部署安全约定**:`confs/configs/*.ini` 含敏感字段(`ca.ini` 管理员白名单是 `is_admin` 判定依据、`smtp.ini` 密码字段),权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可直接提权为管理员或劫持邮件验证码(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。 +- **部署安全约定**:`confs/configs/*.ini` 含敏感字段(如 `smtp.ini` 密码字段),权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可直接劫持邮件验证码(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args,其防护对象相应转为 `services.yaml` 的文件权限。 ### aibox(独立 AI 智能体系统) diff --git a/ca/Makefile b/ca/Makefile index fc0bbe4..90e08f2 100644 --- a/ca/Makefile +++ b/ca/Makefile @@ -1,4 +1,4 @@ -VERSION := 1.0.0 +VERSION := 1.1.0 APP_NAME := ca INSTL_DIR := /usr/local/bin CPP := g++ @@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR)) all: $(BUILD_DIR)/$(APP_NAME).out $(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o $(CPP) $^ $(LIBS) $(MY_LIBS) -o $@ -$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp +$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/ExpBase.hpp inc/LocalTime.hpp $(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@ clear: @rm -rvf build/ diff --git a/ca/README.md b/ca/README.md index e0af986..88b622b 100644 --- a/ca/README.md +++ b/ca/README.md @@ -21,7 +21,7 @@ UniServe Framework 的中央认证服务(Central Auth),C++20 实现,v1.0 - 无 `Target-Url` 头 = 公开请求:剥离伪造身份头(`X-Auth-User`/`X-Auth-Admin`)后直转。 - 有 `Target-Url` 头 = 受保护请求:本地验签(RSA + 过期 + jti 黑名单 + 用户版本号),通过后注入 `X-Auth-User`(管理员追加 `X-Auth-Admin: 1`)与 `Authorization: Bearer` 再转发。 - 上游不可达/超时统一返回 `502 Bad Gateway`(区别于 CA 自身故障的 500)。 -- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务。 +- **WebSocket 反向代理(仅管理员)**:`--ws-proxy-routes` 配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务。 - **CAPTCHA 代理**:`GET /captcha/image` 代理 captcha2 签发图形验证码(PNG + `X-Captcha-Token` 响应头,10 次/分钟/IP,禁缓存)。 - **邮箱验证码**:`/register/send_code`、`/reset/send_code` 发送 6 位验证码邮件(10 分钟有效);`/reset_password` 重置密码(防枚举恒成功 + 改密后版本号吊销全端 Token)。 - **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 @@ -48,8 +48,8 @@ gateway 侧仅做**存在性不变量**检查(显式 `Target-Url` 请求必须 | 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack | | 防用户枚举 | 登录对不存在用户执行 dummy PBKDF2 拉平时序;重置密码恒返回成功 | | CAPTCHA | 统一走 captcha2 远端校验(Token 一次性消费);`/login` 强制 | -| TLS 可选 | `Server.tls = off` 时纯 HTTP(配合 WAF),`on` 时用 `full_chain.crt` / `server.key` | -| 下游容错 | pymsg 客户端 `retry_on_refused`:db/confs 未就绪时阻塞等待;日志客户端 fail-safe 静默丢弃 | +| TLS 可选 | 缺省(不传 `--tls`)纯 HTTP(配合 WAF),`--tls` 时用 `full_chain.crt` / `server.key` | +| 下游容错 | pymsg 客户端 `retry_on_refused`:db 未就绪时阻塞等待;日志客户端 fail-safe 静默丢弃 | ## 接口 @@ -93,32 +93,46 @@ Target-Url: http://127.0.0.1:8006 // 可选;携带即视为 - 有 `Target-Url`:验签通过后注入身份头转发;`Target-Url` 白名单由 gateway 侧 `Proxy.opened` 裁决(403)。 - 上游故障:`502 {"success": false, "message": "Bad Gateway"}`。 -### WebSocket 代理(`[WsProxy]` 配置的路径) +### WebSocket 代理(`--ws-proxy-routes` 配置的路径) 握手阶段从 `Authorization: Bearer` / `Cookie: q4_token` 提取 Token,验签且 `is_admin=true` 才升级连接并桥接后端;否则拒绝。路由精确匹配,优先级高于 HTTP 通配 `*`。 ## 配置 -两层加载:**confs 统一配置服务**(启动时 pymsg `get` 拉取 `ca.ini`,`retry_on_refused=true`)→ **环境变量覆盖**(最高优先级)。 +v1.1.0 起配置**全部经 CLI 传递**,不再依赖 confs 统一配置服务(与 dnfs2 同模式)。三层优先级:**CLI 参数 > 环境变量 > 代码默认值**;部署侧由 [`core/services.yaml`](../core/services.yaml) 的 `args` 声明完整传入。 -配置文件:[confs/configs/ca.ini](../confs/configs/ca.ini)。 +| CLI 选项 | 环境变量(CA_ 前缀优先,兼容旧名) | 默认值 | +|----------|-----------------------------------|--------| +| `--host` | `CA_HOST` / `LISTEN_ADDR` | `127.0.0.1` | +| `--port` | `CA_PORT` / `LISTEN_PORT` | `8443` | +| `--tls` | `CA_TLS=on` | 关(纯 HTTP) | +| `--upstream-gateway` | `CA_UPSTREAM_GATEWAY` / `UPSTREAM_GATEWAY` | `http://127.0.0.1:8445` | +| `--token-keep-time` | `CA_TOKEN_KEEP_TIME` | `3600` | +| `--token-refresh-grace-multiplier` | `CA_TOKEN_REFRESH_GRACE_MULTIPLIER` | `2` | +| `--token-max-refresh` | `CA_TOKEN_MAX_REFRESH` | `10` | +| `--cors-origins` | `CA_CORS_ORIGINS` | 空 | +| `--admin-users` | `CA_ADMIN_USERS` | 空 | +| `--ws-proxy-routes` | `CA_WS_PROXY_ROUTES` | 空 | +| `--db-host` / `--db-port` | `CA_DB_HOST`/`DB_HOST` / `CA_DB_PORT`/`DB_PORT` | `127.0.0.1:8005` | +| `--captcha-host` / `--captcha-port` | `CA_CAPTCHA_HOST`/`CAPTCHA_HOST` / `CA_CAPTCHA_PORT`/`CAPTCHA_PORT` | `127.0.0.1:8009` | +| `--smtp-host` / `--smtp-port` | `CA_SMTP_HOST`/`SMTP_HOST` / `CA_SMTP_PORT`/`SMTP_PORT` | `127.0.0.1:8008` | +| `--instance` | — | `ca` | +| `--log-level` | — | `info` | -- `Server.address`/`port`/`tls`:监听地址/端口/TLS(默认 `127.0.0.1:8443`,env `LISTEN_ADDR`/`LISTEN_PORT`)。 -- `Upstream.gateway`:唯一上游 gateway 地址(默认 `http://127.0.0.1:8445`,env `UPSTREAM_GATEWAY`)。 -- `Token.*`:`keep_time` / `refresh_grace_multiplier` / `max_refresh`。 -- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 -- `CORS.allowed_origins`:已读取但尚未用于预检响应(见 [`ca_SECURITY_AUDIT.md`](../docs/security/ca_SECURITY_AUDIT.md) P2-3)。 -- `WsProxy.routes`:`/path=ws://host:port/target`,分号分隔。 -- `Downstream.*`:db/captcha/smtp 地址(env `DB_*`/`CAPTCHA_*`/`SMTP_*`/`CONFS_*`)。 +- `--admin-users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 +- `--cors-origins`:已读取但尚未用于预检响应(见 [`ca_SECURITY_AUDIT.md`](../docs/security/ca_SECURITY_AUDIT.md) P2-3)。 +- `--ws-proxy-routes`:`/path=ws://host:port/target`,分号分隔多条。 +- 旧环境变量名(`LISTEN_ADDR` 等)保留兼容,供 Docker 部署用服务名覆盖 `127.0.0.1`。 +- 启动时做配置完整性校验:监听地址/端口、上游 gateway、db 地址非法则启动失败。 ## 编译与运行 ```bash bash _set.sh # make clean && make -bash _run.sh # 从 confs 拉取配置启动(先 sleep 2 等待 confs/db) +bash _run.sh # 纯 CLI 配置启动("$@" 透传,可临时覆盖任意配置项) ``` -命令行参数:`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `ca`)。 +命令行参数:完整选项见 `--help`(见上表);`-h/--help`、`-v/--version`。日志经 LogC 异步批量推送 db:8005 `Logs` 表(service 名 `ca`)。 ## 目录结构 @@ -129,7 +143,6 @@ ca/ ├── inc/ │ ├── DbC.hpp # db 服务客户端(pymsg,retry_on_refused=true;client 名 "ca") │ ├── LogC.hpp # 统一日志客户端(异步批量,fail-safe;client 名 "ca-log") -│ ├── Conf.hpp # ConfIni:ini 解析 + env 覆盖 │ ├── ExpBase.hpp # 异常基类(带 source_location) │ └── LocalTime.hpp # 本地时间工具 ├── Makefile @@ -139,4 +152,6 @@ ca/ ## 版本 +`1.1.0`:配置体系重构——彻底移除 confs 依赖(删 `Conf.hpp` 与 `confs/configs/ca.ini`),配置全部经 CLI 传递(CLI > 环境变量 > 默认值三层),原 ca.ini 配置值迁入 `core/services.yaml` 的 ca 条目 args;修复伪造 Token 触发 500(RSA 验签异常未捕获,现统一返回 401,符合黑盒测试 B3/B4 契约)。 + `1.0.0`:认证独立化首发——自 gateway v0.8.0 整体搬迁全部认证资产(逐字保留实现),新增前置转发层(固定上游 + strip-then-set 身份头契约 + 上游 502 语义)。原 gateway 同步瘦身为 v0.9.0 纯转发服务。 diff --git a/ca/_run.sh b/ca/_run.sh index 67da3b7..255f6d6 100755 --- a/ca/_run.sh +++ b/ca/_run.sh @@ -1,7 +1,8 @@ #!/bin/bash # ca 启动脚本 -# 配置统一由 confs 服务管理(confs/configs/ca.ini), -# 启动时从 confs(CONFS_HOST:CONFS_PORT,默认 127.0.0.1:8010)拉取配置。 +# 配置完全经 CLI 参数传递(v1.1.0 起不再依赖 confs), +# 部署侧由 core/services.yaml 的 args 声明完整传入; +# "$@" 透传允许命令行临时覆盖任意配置项。 cd "$(dirname "$0")" || exit # sleep 已移至 services.yaml 的 sleep_before 字段统一管理 diff --git a/ca/inc/Conf.hpp b/ca/inc/Conf.hpp deleted file mode 100644 index a0b7d82..0000000 --- a/ca/inc/Conf.hpp +++ /dev/null @@ -1,77 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include -#include -namespace property_tree = boost::property_tree; -using ptree = property_tree::ptree; -namespace qing { - - class ConfIni { - public: - /// 默认构造(空配置,后续通过赋值覆盖,仅供 confs 加载流程内部使用) - ConfIni() = default; - - /// 从文件路径解析 ini(保留用于开发/调试模式) - ConfIni(const std::string &filename) { - if (filename.empty()) return; // 空文件名不报错,后续会通过赋值覆盖 - try { - std::cout << "[conf] 从文件加载配置: " << filename << std::endl; - property_tree::ini_parser::read_ini(filename, pt); - } - catch (const boost::property_tree::ini_parser_error& e) { - std::cerr << "[conf] INI 解析错误: " << e.what() << std::endl; - throw IniConfigureFileParseException("INI parsing error!"); - } - } - - /// 从 ini 文本字符串解析(用于从 confs 拉取后解析) - static ConfIni fromString(const std::string &ini_text) { - ConfIni c; - try { - std::istringstream iss(ini_text); - property_tree::ini_parser::read_ini(iss, c.pt); - } - catch (const boost::property_tree::ini_parser_error& e) { - std::cerr << "[conf] INI 文本解析错误: " << e.what() << std::endl; - throw IniConfigureFileParseException("INI parsing error!"); - } - return c; - } - - template - T get(const std::string &path, const T &_defaut) { - return pt.get(path, _defaut); - } - - /// 读取环境变量,不存在则返回默认值 - static std::string env(const char *name, const std::string &default_val = "") { - const char *v = std::getenv(name); - return v ? std::string(v) : default_val; - } - - static int env_int(const char *name, int default_val) { - const char *v = std::getenv(name); - if (!v || !*v) return default_val; - try { return std::stoi(v); } - catch (...) { return default_val; } - } - - class IniConfigureFileParseException: public std::exception { - public: - explicit IniConfigureFileParseException(const std::string &msg) - : message(msg) {} - const char* what() const noexcept override { - return message.c_str(); - } - private: - std::string message; - }; - - private: - ptree pt; - }; -} diff --git a/ca/src/main.cpp b/ca/src/main.cpp index aa163c6..584a916 100644 --- a/ca/src/main.cpp +++ b/ca/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -23,7 +24,6 @@ #include #include "ExpBase.hpp" #include "LocalTime.hpp" -#include "Conf.hpp" #include "DbC.hpp" #include @@ -32,7 +32,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "1.0.0"; +constexpr const char *_VER = "1.1.0"; ////////////////////////////////// #include @@ -376,28 +376,41 @@ std::string extract_token_from_request(const http::request& r // 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀) TokenInfo verify_token(const std::string& raw_token); -/* configure items */ -static std::string _ADDR; -static int _PORT; -static bool _TLS = false; -/* 上游 gateway 地址(前置拓扑:ca 认证后转发至此),由 [Upstream] gateway 配置 */ -static std::string _UPSTREAM_GATEWAY = "http://127.0.0.1:8445"; -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 +/* 环境变量读取辅助(v1.1.0 配置体系 CLI > env > 默认值) */ +static std::string env_or(const char* name, const std::string& def = "") { + const char* v = std::getenv(name); + return (v && *v) ? std::string(v) : def; +} +static int env_int_or(const char* name, int def) { + const char* v = std::getenv(name); + if (!v || !*v) return def; + try { return std::stoi(v); } + catch (...) { return def; } +} +static bool env_flag(const char* name) { + std::string v = env_or(name); + return v == "1" || v == "on" || v == "true" || v == "yes"; +} -/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +/* configure items(默认值即声明处初值,env 与 CLI 依次覆盖) */ +static std::string _ADDR = "127.0.0.1"; +static int _PORT = 8443; +static bool _TLS = false; +/* 上游 gateway 地址(前置拓扑:ca 认证后转发至此) */ +static std::string _UPSTREAM_GATEWAY = "http://127.0.0.1:8445"; +static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒) +static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新 +static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数 +static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单(分号分隔) +static std::vector _ADMIN_USERS; // 管理员账号白名单(分号分隔),登录时写入 Token payload 的 is_admin + +/* 下游 pymsg 服务地址 */ static std::string _DB_HOST = "127.0.0.1"; static int _DB_PORT = 8005; static std::string _CAPTCHA_HOST = "127.0.0.1"; static int _CAPTCHA_PORT = 8009; static std::string _SMTP_HOST = "127.0.0.1"; static int _SMTP_PORT = 8008; -/* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ -static std::string _CONFS_HOST = "127.0.0.1"; -static int _CONFS_PORT = 8010; /* WebSocket 代理路由:path -> ws://backend-host:port/target * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ @@ -620,9 +633,16 @@ TokenInfo verify_token(const std::string& raw_token) { return info; } - // RSA 验签 + // RSA 验签(Verify 对非法签名格式(如奇数长度 hex)会抛异常, + // 此处统一捕获按验签失败处理,避免伪造 Token 触发 500——审计 B3/B4 契约要求 401) auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem()); - if (!publ.Verify(auth, sign)) { + bool sig_ok = false; + try { + sig_ok = publ.Verify(auth, sign); + } catch (const std::exception&) { + sig_ok = false; + } + if (!sig_ok) { info.http_status = 401; return info; } @@ -767,14 +787,27 @@ void set_signal_action() { /* -------- 入口 --------*/ int main(int argc, char **argv) { - /* 首先处理命令行参数 */ + /* 首先处理命令行参数(v1.1.0 起配置全部经 CLI 传递) */ po::options_description desc("cpp language web ca (central auth)"); desc.add_options() ("help,h", "output help message") ("version,v", "output version message") - ("host", po::value(), "绑定地址(默认 127.0.0.1),覆盖 [Server] address") - ("port", po::value(), "监听端口(默认 8443),覆盖 [Server] port") - ("config", po::value(), "confs 拉取的配置名(默认 ca.ini)") + ("host", po::value(), "绑定地址(默认 127.0.0.1,env CA_HOST/LISTEN_ADDR)") + ("port", po::value(), "监听端口(默认 8443,env CA_PORT/LISTEN_PORT)") + ("tls", "启用 HTTPS(证书 full_chain.crt/server.key;缺省纯 HTTP,env CA_TLS=on)") + ("upstream-gateway", po::value(), "上游 gateway 地址(默认 http://127.0.0.1:8445,env CA_UPSTREAM_GATEWAY/UPSTREAM_GATEWAY)") + ("token-keep-time", po::value(), "Token 有效期秒数(默认 3600,env CA_TOKEN_KEEP_TIME)") + ("token-refresh-grace-multiplier", po::value(), "Token 刷新宽限期倍数(默认 2,env CA_TOKEN_REFRESH_GRACE_MULTIPLIER)") + ("token-max-refresh", po::value(), "Token 最大刷新次数(默认 10,env CA_TOKEN_MAX_REFRESH)") + ("cors-origins", po::value(), "CORS Origin 白名单,分号分隔(env CA_CORS_ORIGINS)") + ("admin-users", po::value(), "管理员账号白名单,分号分隔(env CA_ADMIN_USERS)") + ("ws-proxy-routes", po::value(), "WS 代理路由 /path=ws://host:port,分号分隔(env CA_WS_PROXY_ROUTES)") + ("db-host", po::value(), "db 服务地址(默认 127.0.0.1,env CA_DB_HOST/DB_HOST)") + ("db-port", po::value(), "db 服务端口(默认 8005,env CA_DB_PORT/DB_PORT)") + ("captcha-host", po::value(), "captcha 服务地址(默认 127.0.0.1,env CA_CAPTCHA_HOST/CAPTCHA_HOST)") + ("captcha-port", po::value(), "captcha 服务端口(默认 8009,env CA_CAPTCHA_PORT/CAPTCHA_PORT)") + ("smtp-host", po::value(), "smtp 服务地址(默认 127.0.0.1,env CA_SMTP_HOST/SMTP_HOST)") + ("smtp-port", po::value(), "smtp 服务端口(默认 8008,env CA_SMTP_PORT/SMTP_PORT)") ("log-level", po::value(), "日志级别 debug|info|warn|error(默认 info)") ("instance", po::value(), "实例名(注册中心用,默认 ca)") ; @@ -808,133 +841,98 @@ int main(int argc, char **argv) { return 0; } - /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== - * 与 Python 服务(dnfs/db/static)保持一致的加载模式: - * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 - * 2. 从 confs 拉取 ca.ini 全文(retry_on_refused=true 等待 confs 就绪) - * 3. 用 boost::property_tree 解析 ini - * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) - * 5. 配置完整性校验:必需字段缺失则启动失败 - * 不再支持本地 ini 文件回退(与 static 模块一致,配置统一由 confs 管理)。 - * ==================================================================================== */ + /* ==================== 配置加载(v1.1.0:CLI > 环境变量 > 默认值) ==================== + * 配置完全经命令行传递(与 dnfs2 同模式),不再依赖 confs 统一配置服务: + * 1. 默认值:各配置项声明处初值(与原 ca.ini 默认值一致) + * 2. 环境变量覆盖:CA_ 前缀为主,兼容旧名(LISTEN_ADDR / LISTEN_PORT / UPSTREAM_GATEWAY / DB_x / CAPTCHA_x / SMTP_x,Docker 环境需要服务名而非 127.0.0.1) + * 3. CLI 参数覆盖:最高优先级 + * 4. 配置完整性校验:必需字段非法则启动失败 + * 部署侧由 core/services.yaml 的 args 声明完整传入配置。 + * ============================================================================== */ { - /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ - _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); - _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); - - /* --config 覆盖 confs 拉取的配置名(在 confs 拉取前读取) */ - std::string confs_config_name = "ca.ini"; - if (vm.count("config")) { - confs_config_name = vm["config"].as(); - } - - ConfIni cf; - try { - /* 从 confs 拉取配置 */ - std::cout << "[ca] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT - << ") 拉取 " << confs_config_name << " ..." << std::endl; - pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "ca"); - json::object req; - req["_route"] = "get"; - req["name"] = confs_config_name; - std::string resp_str = confs_client.call( - json::serialize(req), /*timeout=*/5.0, - /*retry_on_refused=*/true); - auto resp = json::parse(resp_str).as_object(); - if (!resp.at("success").as_bool()) { - std::string err = "unknown"; - if (auto* ev = resp.if_contains("error")) { - if (ev->is_string()) err = ev->as_string().c_str(); - } - std::cerr << "[ca] 致命错误: 从 confs 获取 ca.ini 失败: " << err << std::endl; - return 1; - } - std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); - cf = ConfIni::fromString(ini_content); - std::cout << "[ca] 配置来源: confs (ca.ini @ " - << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; - } - catch (ConfIni::IniConfigureFileParseException &e) { - std::cerr << "ERROR: " << e.what() << std::endl; - std::cerr << "ini parse error" << std::endl; - return 1; - } - catch (const std::exception &e) { - std::cerr << "[ca] 致命错误: 配置加载失败: " << e.what() << std::endl; - return 1; - } - - /* 2. 从 ini 读取配置 */ - _ADDR = cf.get("Server.address", ""); - _PORT = cf.get("Server.port", -1); - _TLS = cf.get("Server.tls", false); - _UPSTREAM_GATEWAY = cf.get("Upstream.gateway", "http://127.0.0.1:8445"); - _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", ""), ';'); - - /* 下游服务地址 */ - _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); - _DB_PORT = cf.get("Downstream.db_port", 8005); - _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); - _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); - _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); - _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); - - /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ + /* 1. 环境变量覆盖(默认值之上) */ { - std::string ws_cfg = cf.get("WsProxy.routes", ""); - if (!ws_cfg.empty()) { - auto entries = split(ws_cfg, ';'); - for (auto& entry : entries) { - auto eq = entry.find('='); - if (eq != std::string::npos) { - std::string path = entry.substr(0, eq); - std::string url = entry.substr(eq + 1); - auto trim = [](std::string& s) { - while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); - while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); - }; - trim(path); trim(url); - if (!path.empty() && !url.empty()) { - _WS_PROXY_ROUTES[path] = url; - } - } - } - } + std::string v = env_or("CA_HOST", env_or("LISTEN_ADDR")); + if (!v.empty()) _ADDR = v; + } + { + int v = env_int_or("CA_PORT", env_int_or("LISTEN_PORT", -1)); + if (v > 0) _PORT = v; + } + if (env_flag("CA_TLS")) _TLS = true; + + { + std::string v = env_or("CA_UPSTREAM_GATEWAY", env_or("UPSTREAM_GATEWAY")); + if (!v.empty()) _UPSTREAM_GATEWAY = v; + } + { + int v = env_int_or("CA_TOKEN_KEEP_TIME", -1); + if (v > 0) _TOKEN_KEEP_TIME = v; + } + { + int v = env_int_or("CA_TOKEN_REFRESH_GRACE_MULTIPLIER", -1); + if (v > 0) _TOKEN_REFRESH_GRACE_MULTIPLIER = v; + } + { + int v = env_int_or("CA_TOKEN_MAX_REFRESH", -1); + if (v > 0) _TOKEN_MAX_REFRESH = v; + } + { + std::string v = env_or("CA_CORS_ORIGINS"); + if (!v.empty()) _CORS_ORIGINS = split(v, ';'); + } + { + std::string v = env_or("CA_ADMIN_USERS"); + if (!v.empty()) _ADMIN_USERS = split(v, ';'); } - /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ - std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); - if (!env_listen_addr.empty()) _ADDR = env_listen_addr; - int env_listen_port = ConfIni::env_int("LISTEN_PORT", -1); - if (env_listen_port > 0) _PORT = env_listen_port; + { + std::string v = env_or("CA_DB_HOST", env_or("DB_HOST")); + if (!v.empty()) _DB_HOST = v; + } + { + int v = env_int_or("CA_DB_PORT", env_int_or("DB_PORT", -1)); + if (v > 0) _DB_PORT = v; + } + { + std::string v = env_or("CA_CAPTCHA_HOST", env_or("CAPTCHA_HOST")); + if (!v.empty()) _CAPTCHA_HOST = v; + } + { + int v = env_int_or("CA_CAPTCHA_PORT", env_int_or("CAPTCHA_PORT", -1)); + if (v > 0) _CAPTCHA_PORT = v; + } + { + std::string v = env_or("CA_SMTP_HOST", env_or("SMTP_HOST")); + if (!v.empty()) _SMTP_HOST = v; + } + { + int v = env_int_or("CA_SMTP_PORT", env_int_or("SMTP_PORT", -1)); + if (v > 0) _SMTP_PORT = v; + } - std::string env_upstream = ConfIni::env("UPSTREAM_GATEWAY"); - if (!env_upstream.empty()) _UPSTREAM_GATEWAY = env_upstream; + /* WS 代理路由 env:/path=ws://host:port/target;...(解析逻辑与 CLI 共用) */ + std::string ws_cfg = env_or("CA_WS_PROXY_ROUTES"); - std::string env_db_host = ConfIni::env("DB_HOST"); - if (!env_db_host.empty()) _DB_HOST = env_db_host; - int env_db_port = ConfIni::env_int("DB_PORT", -1); - if (env_db_port > 0) _DB_PORT = env_db_port; - - std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); - if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; - int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); - if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; - - std::string env_smtp_host = ConfIni::env("SMTP_HOST"); - if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; - int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); - if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; - - /* 4. CLI 参数覆盖(最高优先级) */ - if (vm.count("host")) _ADDR = vm["host"].as(); - if (vm.count("port")) _PORT = vm["port"].as(); - if (vm.count("instance")) _INSTANCE = vm["instance"].as(); + /* 2. CLI 参数覆盖(最高优先级) */ + if (vm.count("host")) _ADDR = vm["host"].as(); + if (vm.count("port")) _PORT = vm["port"].as(); + if (vm.count("tls")) _TLS = true; + if (vm.count("upstream-gateway")) _UPSTREAM_GATEWAY = vm["upstream-gateway"].as(); + if (vm.count("token-keep-time")) _TOKEN_KEEP_TIME = vm["token-keep-time"].as(); + if (vm.count("token-refresh-grace-multiplier")) _TOKEN_REFRESH_GRACE_MULTIPLIER = vm["token-refresh-grace-multiplier"].as(); + if (vm.count("token-max-refresh")) _TOKEN_MAX_REFRESH = vm["token-max-refresh"].as(); + if (vm.count("cors-origins")) _CORS_ORIGINS = split(vm["cors-origins"].as(), ';'); + if (vm.count("admin-users")) _ADMIN_USERS = split(vm["admin-users"].as(), ';'); + if (vm.count("ws-proxy-routes")) ws_cfg = vm["ws-proxy-routes"].as(); + if (vm.count("db-host")) _DB_HOST = vm["db-host"].as(); + if (vm.count("db-port")) _DB_PORT = vm["db-port"].as(); + if (vm.count("captcha-host")) _CAPTCHA_HOST = vm["captcha-host"].as(); + if (vm.count("captcha-port")) _CAPTCHA_PORT = vm["captcha-port"].as(); + if (vm.count("smtp-host")) _SMTP_HOST = vm["smtp-host"].as(); + if (vm.count("smtp-port")) _SMTP_PORT = vm["smtp-port"].as(); + if (vm.count("instance")) _INSTANCE = vm["instance"].as(); if (vm.count("log-level")) { _LOG_LEVEL = vm["log-level"].as(); // 归一化 warning → warn @@ -952,18 +950,38 @@ int main(int argc, char **argv) { } } - /* 5. 配置完整性校验 */ - if (_ADDR.empty() || _PORT == -1) { - std::cerr << "[ca] 致命错误: 配置项 [Server] address/port 缺失" - "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; + /* WS 代理路由解析:/path=ws://host:port/target;... */ + if (!ws_cfg.empty()) { + auto entries = split(ws_cfg, ';'); + for (auto& entry : entries) { + auto eq = entry.find('='); + if (eq != std::string::npos) { + std::string path = entry.substr(0, eq); + std::string url = entry.substr(eq + 1); + auto trim = [](std::string& s) { + while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); + while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); + }; + trim(path); trim(url); + if (!path.empty() && !url.empty()) { + _WS_PROXY_ROUTES[path] = url; + } + } + } + } + + /* 3. 配置完整性校验 */ + if (_ADDR.empty() || _PORT <= 0) { + std::cerr << "[ca] 致命错误: 监听地址/端口非法" + "(--host/--port 或 CA_HOST/CA_PORT 环境变量)" << std::endl; return 1; } if (_UPSTREAM_GATEWAY.empty()) { - std::cerr << "[ca] 致命错误: 配置项 [Upstream] gateway 缺失" << std::endl; + std::cerr << "[ca] 致命错误: 上游 gateway 地址缺失(--upstream-gateway)" << std::endl; return 1; } if (_DB_HOST.empty() || _DB_PORT <= 0) { - std::cerr << "[ca] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; + std::cerr << "[ca] 致命错误: db 服务地址缺失(--db-host/--db-port)" << std::endl; return 1; } } diff --git a/confs/configs/ca.ini b/confs/configs/ca.ini deleted file mode 100644 index 3c8c190..0000000 --- a/confs/configs/ca.ini +++ /dev/null @@ -1,51 +0,0 @@ -[Server] -# CA(Central Auth)前置认证服务:接管原 gateway 的 8443 端口, -# WAF upstream 不变(waf → ca → gateway 前置拓扑)。 -# - tls = off 启用 HTTP 模式(配合 waf 模块使用) -# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) -address = 127.0.0.1 -port = 8443 -tls = off - -[Upstream] -# 前置拓扑的唯一上游:认证通过后转发到 gateway(仅 loopback) -# 环境变量 UPSTREAM_GATEWAY 可覆盖 -gateway = http://127.0.0.1:8445 - -[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 等)凭该字段豁免所有权校验,无需各自维护名单。 -# 修改后需重启 ca 生效;旧 Token 无此字段,按非管理员处理。 -users = 1776177662@qq.com;277665039@qq.com; - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[WsProxy] -# WebSocket 反向代理路由(仅管理员可访问) -# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 -# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), -# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 -# 支持 ws:// 和 wss:// 后端 -# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws -routes = /ws/aibox=ws://127.0.0.1:8765; - -[Downstream] -# 下游 pymsg 服务地址(其他微服务的 host:port) -# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT -db_host = 127.0.0.1 -db_port = 8005 -captcha_host = 127.0.0.1 -captcha_port = 8009 -smtp_host = 127.0.0.1 -smtp_port = 8008 diff --git a/core/services.yaml b/core/services.yaml index b3299b6..38cb423 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -76,7 +76,43 @@ services: - name: ca cmd: bash /opt/micro_servers/ca/_run.sh - args: ["--instance", "ca", "--log-level", "info"] + args: + - "--instance" + - "ca" + - "--log-level" + - "info" + # --- v1.1.0:配置全部经 CLI 传递(原 confs/configs/ca.ini 迁入) --- + - "--host" + - "127.0.0.1" + - "--port" + - "8443" + - "--upstream-gateway" + - "http://127.0.0.1:8445" + - "--token-keep-time" + - "3600" + - "--token-refresh-grace-multiplier" + - "2" + - "--token-max-refresh" + - "10" + # 管理员白名单(分号分隔):登录命中时 Token 写入 is_admin=true + - "--admin-users" + - "1776177662@qq.com;277665039@qq.com" + - "--cors-origins" + - "https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443" + - "--ws-proxy-routes" + - "/ws/aibox=ws://127.0.0.1:8765" + - "--db-host" + - "127.0.0.1" + - "--db-port" + - "8005" + - "--captcha-host" + - "127.0.0.1" + - "--captcha-port" + - "8009" + - "--smtp-host" + - "127.0.0.1" + - "--smtp-port" + - "8008" depends_on: [db2] sleep_before: 2 From 0954644ee378eef74c4f799726e2d43f348b29bf Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 06:40:46 +0000 Subject: [PATCH 039/196] =?UTF-8?q?redirect80:=20=E7=A7=BB=E9=99=A4confs?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=EF=BC=8C=E9=85=8D=E7=BD=AE=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E7=BB=8FCLI=E4=BC=A0=E9=80=92=EF=BC=88CLI=20>=20ENV=20>=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redirect80.py: 移除 configparser/pymsg.RpcClient 导入和 confs 拉取逻辑, 配置改为三层加载(默认值 → 环境变量 → CLI 参数) - core/services.yaml: redirect80 条目展开为完整 CLI 声明 (--host/--port/--redirect-port/--redirect-code) - redirect80/README.md: 配置表新增 CLI 参数列,init.txt 章节重写为 services.yaml 示例 - README.md: 配置链路移除 redirect80,confs 配置表删除 redirect80.ini 行 与 ca v1.1.0 保持一致的配置模式,redirect80 不再依赖 confs 服务。 --- README.md | 5 ++-- core/services.yaml | 15 +++++++++++- redirect80/README.md | 49 +++++++++++++++++++++++----------------- redirect80/redirect80.py | 47 ++++++-------------------------------- 4 files changed, 51 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index a8550bd..3092610 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ └────────────┘ 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 - 配置链路: gateway / db2 / static / redirect80 / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载;ca/dnfs2 例外,配置纯 CLI 传递) + 配置链路: gateway / db2 / static / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载;ca/dnfs2/redirect80 例外,配置纯 CLI 传递) ``` > `db2` / `dnfs2` 分别是 `db` / `dnfs` 的 C++20 重实现(接口完全兼容,可平替),架构位置与 Python 版一致。`BotOps` 为 aibox 的终端前端(WebSocket 客户端),`lci` 为嵌入式帧缓冲图形界面,均不在上图中。 @@ -193,7 +193,7 @@ - 智能重定向:GET/HEAD → 301(搜索引擎友好永久重定向),POST/PUT/DELETE/PATCH → 308(保留方法与 body)。 - Host 保留:读取请求 `Host` 头构造重定向 URL(剥离端口),回退用配置的监听地址。 - 日志集成:通过 `log_client.py` 异步批量推送运行日志到 db 的 Logs 表(fail-safe)。 -- 配置分层:confs 强依赖(`confs/configs/redirect80.ini`,启动时 pymsg 阻塞等待拉取,confs 不可用时直接退出不做本地回退),环境变量(`REDIRECT80_*`)优先级最高。 +- 配置分层:CLI 参数(由 core/services.yaml 传入) > 环境变量(`REDIRECT80_*`) > 代码默认值,不依赖 confs。 - 80 端口为 Linux 特权端口(<1024),需 `CAP_NET_BIND_SERVICE` 或 root;`_run.sh` 自动尝试 `setcap`,或用 systemd `AmbientCapabilities`;详见 [redirect80/README.md](redirect80/README.md)。 ### confs(统一配置服务) @@ -210,7 +210,6 @@ | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | | `db.ini` | db / db2 | 强依赖(confs 未就绪自动重试) | | `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | -| `redirect80.ini` | redirect80 | 强依赖(confs 不可用直接退出) | | `smtp.ini` | smtp / smtp3 | confs 拉取 | | `captcha.ini` | captcha / captcha2 | confs 拉取 | | `example.ini` | —(旧 gateway 示例模板) | 无消费方 | diff --git a/core/services.yaml b/core/services.yaml index 38cb423..b020482 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -139,6 +139,19 @@ services: # --- 端口重定向(最后启动) --- - name: redirect80 cmd: bash /opt/micro_servers/redirect80/_run.sh - args: ["--instance", "redirect80", "--log-level", "info"] + args: + - "--instance" + - "redirect80" + - "--log-level" + - "info" + # --- 配置全部经 CLI 传递(原 confs/configs/redirect80.ini 迁入) --- + - "--host" + - "0.0.0.0" + - "--port" + - "80" + - "--redirect-port" + - "443" + - "--redirect-code" + - "301" depends_on: [waf] sleep_before: 0 diff --git a/redirect80/README.md b/redirect80/README.md index 52dfbd6..8a15b18 100644 --- a/redirect80/README.md +++ b/redirect80/README.md @@ -8,18 +8,18 @@ - **智能重定向**:GET/HEAD → 301(搜索引擎友好永久重定向),POST/PUT/DELETE/PATCH → 308(保留方法和 body) - **Host 保留**:读取请求 `Host` 头构造重定向 URL(剥离端口),回退用配置的监听地址 - **日志集成**:通过 `log_client.py` 异步批量推送运行日志到 `db:8005` 的 Logs 表(fail-safe) -- **配置分层**:confs 强依赖 → 环境变量覆盖(`REDIRECT80_*`),confs 不可用时直接退出不做本地回退 +- **配置分层**:CLI 参数(由 core/services.yaml 传入) > 环境变量覆盖(`REDIRECT80_*`) > 代码默认值 ## 配置 -配置通过 confs 统一配置服务分发(`confs/configs/redirect80.ini`),启动时经 pymsg 阻塞等待拉取(retry_on_refused),confs 不可用时进程直接退出。环境变量优先级最高。 +配置通过三层加载(CLI > 环境变量 > 默认值),全部配置项由 core/services.yaml 经 CLI 参数传入,不依赖 confs。 -| 配置项 | 环境变量 | 默认值 | 说明 | -|--------|----------|--------|------| -| `host` | `REDIRECT80_HOST` | `0.0.0.0` | 监听地址 | -| `port` | `REDIRECT80_PORT` | `80` | 监听端口 | -| `redirect_port` | `REDIRECT80_REDIRECT_PORT` | `443` | 重定向目标 HTTPS 端口 | -| `redirect_code` | `REDIRECT80_REDIRECT_CODE` | `301` | 非 GET/HEAD 重定向状态码(308 vs 307) | +| 配置项 | CLI 参数 | 环境变量 | 默认值 | 说明 | +|--------|----------|----------|--------|------| +| `host` | `--host` | `REDIRECT80_HOST` | `0.0.0.0` | 监听地址 | +| `port` | `--port` | `REDIRECT80_PORT` | `80` | 监听端口 | +| `redirect_port` | `--redirect-port` | `REDIRECT80_REDIRECT_PORT` | `443` | 重定向目标 HTTPS 端口 | +| `redirect_code` | `--redirect-code` | `REDIRECT80_REDIRECT_CODE` | `301` | GET/HEAD 重定向状态码 | ## 安装 & 运行 @@ -45,21 +45,28 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE ExecStart=/opt/micro_servers/redirect80/_run.sh ``` -## 在 core 的 init.txt 中 +## 在 core 的 services.yaml 中 -``` -bash /opt/micro_servers/redirect80/_run.sh -``` +redirect80 作为最后一个服务启动,配置全部经 CLI 传入: -建议放在 waf 之前: - -``` -bash /opt/micro_servers/db/_run.sh -bash /opt/micro_servers/confs/_run.sh -bash /opt/micro_servers/redirect80/_run.sh # ← 80→443 重定向 -bash /opt/micro_servers/waf/_run.sh -bash /opt/micro_servers/gateway/_run.sh -... +```yaml +- name: redirect80 + cmd: bash /opt/micro_servers/redirect80/_run.sh + args: + - "--instance" + - "redirect80" + - "--log-level" + - "info" + - "--host" + - "0.0.0.0" + - "--port" + - "80" + - "--redirect-port" + - "443" + - "--redirect-code" + - "301" + depends_on: [waf] + sleep_before: 0 ``` ## 端口一览 diff --git a/redirect80/redirect80.py b/redirect80/redirect80.py index 0d2465e..2c77132 100644 --- a/redirect80/redirect80.py +++ b/redirect80/redirect80.py @@ -8,14 +8,12 @@ redirect80 — HTTP 80 端口重定向服务。 日志通过 log_client 异步推送到 db 的 Logs 表(fail-safe)。 配置(三层加载): - 1. confs 强依赖拉取 redirect80.ini(retry_on_refused 阻塞等待) - 2. 环境变量覆盖(Docker/k8s 兼容) - 3. 命令行参数(最高优先级) -配置不可用时直接退出,不做本地回退。 + 1. 代码默认值 + 2. 环境变量覆盖(REDIRECT80_* 前缀,Docker/k8s 兼容) + 3. 命令行参数(最高优先级,由 core/services.yaml 传入) """ import argparse -import configparser import os import sys as _sys from http.server import HTTPServer, BaseHTTPRequestHandler @@ -26,14 +24,11 @@ _here = os.path.dirname(os.path.abspath(__file__)) _sys.path.insert(0, os.path.join(_here, "..")) from log_client import logs, set_log_level # noqa: E402 -_sys.path.insert(0, os.path.join(_here, "..", "pymsg")) -from pymsg import RpcClient # noqa: E402 - # ── 配置加载 ───────────────────────────────────────────────────────── def load_config(argv): - """三层加载: confs → ENV → CLI(最高优先级)。""" + """三层加载: 默认值 → ENV → CLI(最高优先级)。""" # argparse 先行:--help 在任何网络调用前处理 parser = argparse.ArgumentParser( @@ -41,7 +36,7 @@ def load_config(argv): description="HTTP 80 端口重定向服务", add_help=False, formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="配置优先级: CLI > 环境变量 > confs > 默认值", + epilog="配置优先级: CLI > 环境变量 > 默认值", ) parser.add_argument("--host", default=None, help="绑定地址,默认 0.0.0.0") @@ -69,35 +64,7 @@ def load_config(argv): "instance": "redirect80", } - # 1. confs 强依赖拉取配置(阻塞等待 confs 就绪,失败则退出) - confs_host = os.environ.get("CONFS_HOST", "127.0.0.1") - confs_port = int(os.environ.get("CONFS_PORT", "8010")) - try: - client = RpcClient( - confs_host, confs_port, "redirect80", - retry_on_refused=True, timeout=5, - ) - data = client.call("get", {"name": "redirect80.ini"}) - ini_text = data.get("content", "") - ini = configparser.ConfigParser(interpolation=None) - ini.read_string(ini_text) - - _merge = { - ("server", "host"): "host", - ("server", "port"): "port", - ("server", "redirect_port"): "redirect_port", - ("server", "redirect_code"): "redirect_code", - } - for (sec, key), var in _merge.items(): - if ini.has_option(sec, key): - val = ini.get(sec, key) - config[var] = type(config[var])(val) - logs.info("redirect80", "配置已从 confs 加载") - except Exception as e: - print(f"[redirect80] confs 获取失败: {e}", file=_sys.stderr) - _sys.exit(1) - - # 2. 环境变量覆盖 + # 1. 环境变量覆盖 _env_map = { "REDIRECT80_HOST": "host", "REDIRECT80_PORT": ("port", int), @@ -112,7 +79,7 @@ def load_config(argv): else: config[mapping] = val - # 3. CLI 参数(最高优先级) + # 2. CLI 参数(最高优先级) if args.host is not None: config["host"] = args.host if args.port is not None: From 80969adc9b235657357c9aa6be2e0c4edd6b2242 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 09:03:28 +0000 Subject: [PATCH 040/196] =?UTF-8?q?db2:=20=E7=A7=BB=E9=99=A4confs=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=EF=BC=8C=E9=85=8D=E7=BD=AE=E5=85=A8=E9=83=A8=E7=BB=8F?= =?UTF-8?q?CLI=E4=BC=A0=E9=80=92=EF=BC=88CLI=20>=20ENV=20>=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db2.cpp: 移除 RpcClient/parse_ini/IniMap,load_config 改为 默认值 → ENV → CLI 三层;新增 --db-url/--retention-days/ --max-records/--cleanup-interval CLI 参数 - core/services.yaml: db2 条目展开为完整 CLI 声明(10个配置项) - db2/README.md: 配置章节重写为 CLI 参数表 + 环境变量表 + services.yaml 示例 - README.md: 配置链路移除 db2,confs 配置表删除 db.ini 行 与 ca/redirect80 保持一致的配置模式,db2 不再依赖 confs 服务。 --- README.md | 5 +- core/services.yaml | 21 ++++++- db2/README.md | 93 +++++++++++++++------------- db2/src/db2.cpp | 151 +++++++++------------------------------------ 4 files changed, 101 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 3092610..1f4ea8c 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ └────────────┘ 日志链路: waf / ca / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs / redirect80 ──(log_client, pymsg)──► db:8005 (Logs 表) 认证链路(v0.9.0 起): waf:443 ──HTTP──► ca:8443 (认证+前置转发) ──HTTP──► gateway:8445 (纯转发, 仅 loopback) ──► 内部服务 - 配置链路: gateway / db2 / static / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载;ca/dnfs2/redirect80 例外,配置纯 CLI 传递) + 配置链路: gateway / static / smtp / captcha ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载;ca/dnfs2/redirect80/db2 例外,配置纯 CLI 传递) ``` > `db2` / `dnfs2` 分别是 `db` / `dnfs` 的 C++20 重实现(接口完全兼容,可平替),架构位置与 Python 版一致。`BotOps` 为 aibox 的终端前端(WebSocket 客户端),`lci` 为嵌入式帧缓冲图形界面,均不在上图中。 @@ -160,7 +160,7 @@ - per-thread 连接:每个 worker 线程持有独立 `sqlite3*`(`thread_local`),列 schema 加锁缓存;SQLite PRAGMA 对齐 Python 版(WAL / NORMAL / busy_timeout=5000)。 - 内置 Logs 表(启动自动建表 + 索引)与后台清理线程(保留 30 天 / 上限 100 万条),设计与 Python db 一致。 - 安全机制:表名白名单(字母数字下划线)、`sqlite3_bind_text` 参数绑定、仅反射已有表无法建删表、RpcServer 异常脱敏。 -- 配置从 confs 拉取 `db.ini`(强依赖,confs 未就绪自动重试),环境变量(`LISTEN_ADDR`/`LISTEN_PORT`/`WORKERS`/`DATABASE_URL`)覆盖,命令行参数最高优先级;详见 [db2/README.md](db2/README.md)。 +- 配置全部经 CLI 传递(CLI > 环境变量 > 默认值,原 `db.ini` 迁入 `core/services.yaml` 的 CLI args),不依赖 confs;详见 [db2/README.md](db2/README.md)。 ### ai_review(AI 内容审核服务) @@ -208,7 +208,6 @@ | 配置文件 | 消费模块 | 依赖强度 | |----------|----------|----------| | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | -| `db.ini` | db / db2 | 强依赖(confs 未就绪自动重试) | | `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | | `smtp.ini` | smtp / smtp3 | confs 拉取 | | `captcha.ini` | captcha / captcha2 | confs 拉取 | diff --git a/core/services.yaml b/core/services.yaml index b020482..df02630 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -13,7 +13,26 @@ services: # --- 数据层(最先启动) --- - name: db2 cmd: bash /opt/micro_servers/db2/_run.sh - args: ["--instance", "db2", "--log-level", "info"] + args: + - "--instance" + - "db2" + - "--log-level" + - "info" + # --- 配置全部经 CLI 传递(原 confs/configs/db.ini 迁入) --- + - "--host" + - "127.0.0.1" + - "--port" + - "8005" + - "--workers" + - "4" + - "--db-url" + - "sqlite:///database.db" + - "--retention-days" + - "30" + - "--max-records" + - "1000000" + - "--cleanup-interval" + - "3600" depends_on: [] sleep_before: 0 diff --git a/db2/README.md b/db2/README.md index cb7e7d9..6043c00 100644 --- a/db2/README.md +++ b/db2/README.md @@ -11,7 +11,7 @@ db 模块的 C++20 重实现,接口与 Python 版完全兼容:相同的 `exe | where 运算符 | ✅ | ✅ 完全对齐 | | 响应格式 | ✅ | ✅ 完全对齐 | | `$now` 哨兵值 | ✅ | ✅ | -| confs 配置拉取 | ✅ | ✅ | +| confs 配置拉取 | ✅ | ✅ 纯 CLI(已移除 confs 依赖) | | Logs 自动建表 + 清理 | ✅ | ✅ | | 数据库后端 | SQLite / PG / MySQL | SQLite(直接 C API) | | 依赖 | Python 3 + SQLAlchemy | libmsg + boost::json + libsqlite3 | @@ -29,59 +29,68 @@ db 模块的 C++20 重实现,接口与 Python 版完全兼容:相同的 `exe ### 启动 ```bash -./_run.sh # 读 confs 自动获取配置 +./_run.sh # 使用 services.yaml 传入的 CLI 参数 ./_run.sh --addr 127.0.0.1 --port 8005 --workers 4 # 覆盖监听参数 ``` -服务启动时强依赖 confs(拉取 `db.ini`)。confs 未就绪时自动重试,不要求启动顺序。 +配置全部经 CLI 传递(由 core/services.yaml 注入),不依赖 confs。 ## 配置管理 -与 Python db 完全一致:启动时从 confs 服务拉取 `db.ini`,失败则退出(exit code 1)。 +配置通过三层加载(CLI > 环境变量 > 默认值),全部配置项由 core/services.yaml 经 CLI 参数传入,不依赖 confs。 -### confs 配置文件(db.ini) +### CLI 参数 -配置文件托管于 [confs/configs/db.ini](../confs/configs/db.ini): +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--host` / `--addr` / `-a` | 监听地址 | `127.0.0.1` | +| `--port` / `-p` | 监听端口 | `8005` | +| `--workers` / `-w` | 工作线程数 | `4` | +| `--db-url` | 数据库路径 | `sqlite:///database.db` | +| `--retention-days` | 日志保留天数 | `30` | +| `--max-records` | 日志最大条数 | `1000000` | +| `--cleanup-interval` | 日志清理间隔秒数 | `3600` | +| `--log-level` | 日志级别 | `info` | +| `--instance` | 实例名 | `db2` | -```ini -[server] -host = 127.0.0.1 -port = 8005 -workers = 4 +### 环境变量(覆盖默认值,被 CLI 覆盖) -[database] -url = sqlite:///database.db +| 环境变量 | 说明 | +|---------|------| +| `LISTEN_ADDR` | 监听地址 | +| `LISTEN_PORT` | 监听端口 | +| `WORKERS` | 工作线程数 | +| `DATABASE_URL` | 数据库文件路径 | +| `RETENTION_DAYS` | 日志保留天数 | +| `MAX_RECORDS` | 日志最大条数 | +| `CLEANUP_INTERVAL` | 日志清理间隔秒数 | -[logs] -retention_days = 30 -max_records = 1000000 -cleanup_interval = 3600 -``` +### services.yaml 示例 -### 引导配置(confs 连接地址) - -| 来源 | 优先级 | -|------|--------| -| 环境变量 `CONFS_HOST` / `CONFS_PORT` | 最高 | -| 默认值 `127.0.0.1:8010` | 兜底 | - -### 环境变量(最高优先级,覆盖 confs 配置值) - -| 环境变量 | 说明 | 对应配置项 | -|---------|------|-----------| -| `LISTEN_ADDR` | 监听地址 | `[server] host` | -| `LISTEN_PORT` | 监听端口 | `[server] port` | -| `WORKERS` | 工作线程数 | `[server] workers` | -| `DATABASE_URL` | 数据库文件路径 | `[database] url` | -| `CONFS_HOST` | confs 地址(引导) | — | -| `CONFS_PORT` | confs 端口(引导) | — | - -### 命令行参数(最高优先级) - -``` ---addr / -a 监听地址 ---port / -p 监听端口 ---workers / -w 工作线程数 +```yaml +- name: db2 + cmd: bash /opt/micro_servers/db2/_run.sh + args: + - "--instance" + - "db2" + - "--log-level" + - "info" + - "--host" + - "127.0.0.1" + - "--port" + - "8005" + - "--workers" + - "4" + - "--db-url" + - "sqlite:///database.db" + - "--retention-days" + - "30" + - "--max-records" + - "1000000" + - "--cleanup-interval" + - "3600" + depends_on: [] + sleep_before: 0 ``` ## 协议与 API diff --git a/db2/src/db2.cpp b/db2/src/db2.cpp index 53879e3..15161ab 100644 --- a/db2/src/db2.cpp +++ b/db2/src/db2.cpp @@ -12,7 +12,6 @@ */ #include -#include #include @@ -87,46 +86,6 @@ static int safe_stoi(const std::string& s, const char* field_name) { } } -// ============================================================================ -// 工具:简单 INI 解析(白名单模式) -// ============================================================================ - -using IniMap = std::map>; - -static IniMap parse_ini(const std::string& text) { - IniMap result; - std::string section; - std::istringstream ss(text); - std::string line; - while (std::getline(ss, line)) { - // 去首尾空白 - auto b = line.find_first_not_of(" \t\r\n"); - if (b == std::string::npos) continue; - line = line.substr(b); - auto e = line.find_last_not_of(" \t\r\n"); - if (e != std::string::npos) line = line.substr(0, e + 1); - - if (line.empty() || line[0] == '#' || line[0] == ';') continue; - if (line.front() == '[' && line.back() == ']') { - section = line.substr(1, line.size() - 2); - continue; - } - auto eq = line.find('='); - if (eq == std::string::npos || section.empty()) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - // 去 key/val 空白 - auto trim = [](std::string& s) { - auto bb = s.find_first_not_of(" \t"); - auto ee = s.find_last_not_of(" \t"); - s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1); - }; - trim(key); trim(val); - result[section][key] = val; - } - return result; -} - // ============================================================================ // 配置 // ============================================================================ @@ -142,14 +101,9 @@ struct Config { int max_records = 1000000; int cleanup_interval = 3600; - // confs 地址(引导配置,不从 confs 自身获取) - std::string confs_host = "127.0.0.1"; - int confs_port = 8010; - // 标准化 CLI 参数 std::string log_level = "info"; // debug|info|warn|error std::string instance = "db2"; // 实例名(注册中心用) - std::string confs_config_name = "db.ini"; // confs 拉取的配置名 }; // 从 sqlite:///path 或 sqlite:////abs/path 提取文件路径 @@ -164,7 +118,7 @@ static std::string db_url_to_path(const std::string& url) { static Config load_config(int argc, char* argv[]) { Config cfg; - // 0. 预扫描 --help/-h 和 --config(需在 confs 拉取前处理) + // 0. 预扫描 --help/-h for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if (arg == "--help" || arg == "-h") { @@ -172,93 +126,45 @@ static Config load_config(int argc, char* argv[]) { "db2 — C++20 数据库 RPC 服务\n\n" "用法: db2 [选项]\n\n" "选项:\n" - " --host 绑定地址 (别名: --addr/-a)\n" - " --port 监听端口 (别名: -p)\n" - " --workers 工作线程数 (别名: -w)\n" - " --config confs 配置名 (默认: db.ini)\n" - " --log-level debug|info|warn|error (默认: info)\n" - " --instance 实例名 (默认: db2)\n" - " --help, -h 显示帮助并退出\n\n" - "配置优先级: CLI > 环境变量 > confs > 默认值\n"); + " --host 绑定地址 (别名: --addr/-a)\n" + " --port 监听端口 (别名: -p)\n" + " --workers 工作线程数 (别名: -w)\n" + " --db-url 数据库路径 (如 sqlite:///database.db)\n" + " --retention-days 日志保留天数 (默认: 30)\n" + " --max-records 日志最大条数 (默认: 1000000)\n" + " --cleanup-interval 日志清理间隔秒数 (默认: 3600)\n" + " --log-level debug|info|warn|error (默认: info)\n" + " --instance 实例名 (默认: db2)\n" + " --help, -h 显示帮助并退出\n\n" + "配置优先级: CLI > 环境变量 > 默认值\n"); std::exit(0); } - if (arg == "--config" && i + 1 < argc) cfg.confs_config_name = argv[++i]; } - // 1. 从环境变量读取 confs 引导地址 - if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; - if (const char* p = std::getenv("CONFS_PORT")) cfg.confs_port = safe_stoi(p, "CONFS_PORT"); + // 1. 环境变量覆盖(Docker 兼容) + if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; + if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); + if (const char* v = std::getenv("WORKERS")) cfg.workers = safe_stoi(v, "WORKERS"); + if (const char* v = std::getenv("DATABASE_URL")) cfg.db_url = v; + if (const char* v = std::getenv("RETENTION_DAYS")) cfg.retention_days = safe_stoi(v, "RETENTION_DAYS"); + if (const char* v = std::getenv("MAX_RECORDS")) cfg.max_records = safe_stoi(v, "MAX_RECORDS"); + if (const char* v = std::getenv("CLEANUP_INTERVAL")) cfg.cleanup_interval = safe_stoi(v, "CLEANUP_INTERVAL"); - // 2. 从 confs 服务拉取配置(retry_on_refused=true,等待 confs 就绪) - std::string ini_text; - try { - RpcClient confs_client(cfg.confs_host, cfg.confs_port, "db2", - /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); - if (data.is_object() && data.as_object().contains("content")) { - ini_text = std::string(data.as_object().at("content").as_string()); - } - } catch (const std::exception& e) { - std::fprintf(stderr, "[db2] Failed to fetch %s from confs: %s\n", - cfg.confs_config_name.c_str(), e.what()); - std::exit(1); - } - - // 3. 解析 INI(白名单字段) - IniMap ini = parse_ini(ini_text); - - // 校验必需配置项(confs 提供的 db.ini 必须包含完整配置) - static const std::pair required_keys[] = { - {"server", "host"}, - {"server", "port"}, - {"server", "workers"}, - {"database", "url"}, - {"logs", "retention_days"}, - {"logs", "max_records"}, - {"logs", "cleanup_interval"}, - }; - for (auto& [sec, key] : required_keys) { - auto si = ini.find(sec); - if (si == ini.end() || si->second.find(key) == si->second.end()) { - std::fprintf(stderr, "[db2] missing required config: [%s] %s\n", sec, key); - std::exit(1); - } - } - - auto get = [&](const std::string& sec, const std::string& key, - const std::string& def = "") -> std::string { - auto si = ini.find(sec); - if (si == ini.end()) return def; - auto ki = si->second.find(key); - return (ki == si->second.end()) ? def : ki->second; - }; - - cfg.listen_host = get("server", "host", cfg.listen_host); - cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); - cfg.workers = safe_stoi(get("server", "workers",std::to_string(cfg.workers)), "[server] workers"); - cfg.db_url = get("database", "url", cfg.db_url); - cfg.retention_days = safe_stoi(get("logs", "retention_days", std::to_string(cfg.retention_days)), "[logs] retention_days"); - cfg.max_records = safe_stoi(get("logs", "max_records", std::to_string(cfg.max_records)), "[logs] max_records"); - cfg.cleanup_interval = safe_stoi(get("logs", "cleanup_interval", std::to_string(cfg.cleanup_interval)), "[logs] cleanup_interval"); - - // 4. 环境变量最高优先级覆盖(Docker 兼容) - if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; - if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); - if (const char* v = std::getenv("WORKERS")) cfg.workers = safe_stoi(v, "WORKERS"); - if (const char* v = std::getenv("DATABASE_URL")) cfg.db_url = v; - - // 5. 命令行参数(最高优先级) + // 2. 命令行参数(最高优先级) for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); else if ((arg == "--workers" || arg == "-w") && i + 1 < argc) cfg.workers = safe_stoi(argv[++i], "--workers"); + else if (arg == "--db-url" && i + 1 < argc) cfg.db_url = argv[++i]; + else if (arg == "--retention-days" && i + 1 < argc) cfg.retention_days = safe_stoi(argv[++i], "--retention-days"); + else if (arg == "--max-records" && i + 1 < argc) cfg.max_records = safe_stoi(argv[++i], "--max-records"); + else if (arg == "--cleanup-interval" && i + 1 < argc) cfg.cleanup_interval = safe_stoi(argv[++i], "--cleanup-interval"); else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; else if (arg == "--instance" && i + 1 < argc) cfg.instance = argv[++i]; - // --config 和 --help 已在预扫描阶段处理 } - // 6. 数值配置范围校验(防误配置导致异常行为) + // 3. 数值配置范围校验(防误配置导致异常行为) if (cfg.listen_port < 1 || cfg.listen_port > 65535) { std::fprintf(stderr, "[db2] invalid listen_port: %d (must be 1-65535)\n", cfg.listen_port); std::exit(1); @@ -1046,11 +952,10 @@ int main(int argc, char* argv[]) { try { Config cfg = load_config(argc, argv); - std::printf("[db2] listen=%s:%d workers=%d db=%s instance=%s log-level=%s config=%s\n", + std::printf("[db2] listen=%s:%d workers=%d db=%s instance=%s log-level=%s\n", cfg.listen_host.c_str(), cfg.listen_port, cfg.workers, cfg.db_url.c_str(), - cfg.instance.c_str(), cfg.log_level.c_str(), - cfg.confs_config_name.c_str()); + cfg.instance.c_str(), cfg.log_level.c_str()); std::fflush(stdout); DB2Server server(cfg); From 3eafc1560266c3ef93b05e579e3393e2fab6b2f6 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 09:44:38 +0000 Subject: [PATCH 041/196] =?UTF-8?q?smtp3:=20=E8=BF=81=E7=A7=BB=20smtp.ini?= =?UTF-8?q?=20=E8=87=B3=20CLI=20args=EF=BC=8C=E4=BF=AE=E5=A4=8D=20.env=20?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=8E=20TLS=20SNI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - smtp3/src/main.cpp: 移除 confs 依赖(fetch_config_from_confs/merge_ini), 扩展 CLI 参数至 16 项(--instance/--log-level/--smtp-port/--code-len 等), 配置优先级改为 CLI > 环境变量 > 默认值 - smtp3/_run.sh: 新增 ../smtp/.env 加载(与 Python smtp_v2.py 共用凭据), 传递 "",移除 sleep 1 - smtp3/src/main.cpp: SmtpClient 添加 SSL_set_tlsext_host_name (SNI) - core/services.yaml: 回退到 smtp (Python),smtp3 待生产验证后切换 - confs/configs/smtp.ini: 已删除(配置迁入 services.yaml CLI args) - README.md: 移除 smtp.ini 表行,更新部署安全约定 - _inst.sh: 新增 inst_module "smtp3" --- README.md | 3 +- _inst.sh | 1 + confs/configs/smtp.ini | 19 ------ core/services.yaml | 1 + smtp3/_run.sh | 13 +++- smtp3/src/main.cpp | 140 +++++++++++++++-------------------------- 6 files changed, 65 insertions(+), 112 deletions(-) delete mode 100644 confs/configs/smtp.ini diff --git a/README.md b/README.md index 1f4ea8c..a093eaf 100755 --- a/README.md +++ b/README.md @@ -209,11 +209,10 @@ |----------|----------|----------| | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | | `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | -| `smtp.ini` | smtp / smtp3 | confs 拉取 | | `captcha.ini` | captcha / captcha2 | confs 拉取 | | `example.ini` | —(旧 gateway 示例模板) | 无消费方 | -- **部署安全约定**:`confs/configs/*.ini` 含敏感字段(如 `smtp.ini` 密码字段),权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可直接劫持邮件验证码(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args,其防护对象相应转为 `services.yaml` 的文件权限。 +- **部署安全约定**:`confs/configs/*.ini` 含敏感字段,权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可劫持对应服务(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args;原 `smtp.ini` 全部配置已随 smtp3 迁入 `core/services.yaml` 的 CLI args(SMTP 凭据仍经 `.env` 注入),防护对象相应转为 `services.yaml` 与 `.env` 的文件权限。 ### aibox(独立 AI 智能体系统) diff --git a/_inst.sh b/_inst.sh index 24c8fd8..de732f6 100755 --- a/_inst.sh +++ b/_inst.sh @@ -40,6 +40,7 @@ inst_module "dnfs2" inst_module "db" inst_module "db2" inst_module "smtp" +inst_module "smtp3" inst_module "captcha" inst_module "confs" inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像) diff --git a/confs/configs/smtp.ini b/confs/configs/smtp.ini deleted file mode 100644 index 8d946d3..0000000 --- a/confs/configs/smtp.ini +++ /dev/null @@ -1,19 +0,0 @@ -[server] -host = 127.0.0.1 -port = 8008 -workers = 4 - -[smtp] -server = -port = 465 -user = -password = -mail_from = -code_len = 6 -code_ttl = 600 -send_interval = 60 -max_verify_attempts = 5 -email_hourly_limit = 3 -email_daily_limit = 10 -smtp_hourly_limit = 100 -default_subject = DNFS 邮箱验证 diff --git a/core/services.yaml b/core/services.yaml index df02630..d86fe0f 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -73,6 +73,7 @@ services: sleep_before: 0 # --- 邮件服务 --- + # smtp3 已修复 .env 加载 + TLS SNI,待生产验证后切换 - name: smtp cmd: bash /opt/micro_servers/smtp/_run.sh args: ["--instance", "smtp", "--log-level", "info"] diff --git a/smtp3/_run.sh b/smtp3/_run.sh index fd977ec..a9658c5 100755 --- a/smtp3/_run.sh +++ b/smtp3/_run.sh @@ -1,4 +1,13 @@ #!/bin/bash cd "$(dirname "$0")" || exit -sleep 1 -exec ./smtp3.bin + +# 加载 SMTP 凭据(与 Python smtp_v2.py 共用 ../smtp/.env) +# .env 格式:KEY=VALUE(# 开头为注释),set -a 自动 export +ENV_FILE="../smtp/.env" +if [ -f "$ENV_FILE" ]; then + set -a + . "$ENV_FILE" 2>/dev/null + set +a +fi + +exec ./smtp3.bin "$@" diff --git a/smtp3/src/main.cpp b/smtp3/src/main.cpp index bd59427..8e21750 100644 --- a/smtp3/src/main.cpp +++ b/smtp3/src/main.cpp @@ -24,7 +24,6 @@ #include #include -#include #include #include @@ -143,75 +142,7 @@ struct SmtpConfig { int max_content_len = 4096; }; -// 从 confs 拉取 smtp.ini 全文,阻塞等待 confs 就绪 -static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port) { - pmc::net::RpcClient client(confs_host, confs_port, "smtp3", - /*timeout=*/5.0, /*retry_on_refused=*/true); - try { - auto data = client.call("get", {{"name", "smtp.ini"}}); - auto* content = data.as_object().if_contains("content"); - if (!content || !content->is_string()) { - std::cerr << "[smtp3] confs 返回的 smtp.ini 无 content 字段" << std::endl; - std::exit(1); - } - return json::value_to(*content); - } catch (const std::exception& e) { - std::cerr << "[smtp3] 致命错误: 从 confs 获取 smtp.ini 失败: " << e.what() << std::endl; - std::exit(1); - } -} - -// 把 ini 文本合并进 config(仅覆盖已知 section/key,白名单策略) -static void merge_ini(SmtpConfig& cfg, const std::string& text) { - std::istringstream stream(text); - std::string line, section; - while (std::getline(stream, line)) { - auto start = line.find_first_not_of(" \t\r"); - if (start == std::string::npos || line[start] == '#' || line[start] == ';') - continue; - auto end = line.find_last_not_of(" \t\r"); - line = line.substr(start, end - start + 1); - - if (line.front() == '[' && line.back() == ']') { - section = line.substr(1, line.size() - 2); - continue; - } - auto eq = line.find('='); - if (eq == std::string::npos) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - auto ke = key.find_last_not_of(" \t"); - if (ke != std::string::npos) key = key.substr(0, ke + 1); - auto ks = key.find_first_not_of(" \t"); - if (ks != std::string::npos) key = key.substr(ks); - auto vs = val.find_first_not_of(" \t"); - if (vs != std::string::npos) val = val.substr(vs); - auto ve = val.find_last_not_of(" \t"); - if (ve != std::string::npos) val = val.substr(0, ve + 1); - - if (section == "server") { - if (key == "host") cfg.host = val; - else if (key == "port") cfg.port = parse_int(key, val); - else if (key == "workers") cfg.workers = parse_int(key, val); - } else if (section == "smtp") { - if (key == "server") cfg.smtp_server = val; - else if (key == "port") cfg.smtp_port = parse_int(key, val); - else if (key == "user") cfg.smtp_user = val; - else if (key == "password") cfg.smtp_password = val; - else if (key == "mail_from") cfg.mail_from = val; - else if (key == "code_len") cfg.code_len = parse_int(key, val); - else if (key == "code_ttl") cfg.code_ttl = parse_int(key, val); - else if (key == "send_interval") cfg.send_interval = parse_int(key, val); - else if (key == "max_verify_attempts") cfg.max_verify_attempts = parse_int(key, val); - else if (key == "email_hourly_limit") cfg.email_hourly_limit = parse_int(key, val); - else if (key == "email_daily_limit") cfg.email_daily_limit = parse_int(key, val); - else if (key == "smtp_hourly_limit") cfg.smtp_hourly_limit = parse_int(key, val); - else if (key == "default_subject") cfg.default_subject = val; - } - } -} - -// 环境变量覆盖(最高优先级,Docker 兼容;兼容 Python 版 __PASSWORD) +// 环境变量覆盖(Docker 兼容;CLI > 环境变量 > 默认值;兼容 Python 版 __PASSWORD) static void apply_env_overrides(SmtpConfig& cfg) { if (auto* v = std::getenv("LISTEN_ADDR")) cfg.host = v; if (auto* v = std::getenv("LISTEN_PORT")) cfg.port = parse_int("LISTEN_PORT", v); @@ -481,6 +412,8 @@ public: ssl = SSL_new(ctx); if (!ssl) throw std::runtime_error("SSL_new 失败"); SSL_set_fd(ssl, sock); + // SNI: 某些 SMTP 服务器(如 Gmail、Outlook)要求 TLS SNI 扩展 + SSL_set_tlsext_host_name(ssl, cfg_.server.c_str()); if (SSL_connect(ssl) != 1) throw std::runtime_error("TLS 握手失败: " + ssl_error()); @@ -729,20 +662,35 @@ int main(int argc, char* argv[]) { std::signal(SIGPIPE, SIG_IGN); // ── 命令行参数 ── - std::string confs_host = "127.0.0.1"; - int confs_port = 8010; - { - const char* env; - if ((env = std::getenv("CONFS_HOST"))) confs_host = env; - if ((env = std::getenv("CONFS_PORT"))) confs_port = std::stoi(env); - } - po::options_description desc("smtp3 — pymsg 邮箱验证码服务"); desc.add_options() ("help,h", "显示帮助") - ("addr,a", po::value(), "监听地址(覆盖 confs)") - ("port,p", po::value(), "监听端口(覆盖 confs)") - ("workers,w", po::value(), "工作线程数(覆盖 confs)"); + ("instance", po::value()->default_value("smtp"), + "实例名(注册中心用)") + ("log-level", po::value()->default_value("info"), + "日志级别(debug|info|warn|error)") + ("addr,a", po::value(), "监听地址") + ("port,p", po::value(), "监听端口") + ("workers,w", po::value(), "工作线程数") + ("smtp-port", po::value(), "SMTP 服务器端口(SSL,默认 465)") + ("code-len", po::value(), "验证码长度(4~10,默认 6)") + ("code-ttl", po::value(), "验证码有效期秒数(默认 600)") + ("send-interval", po::value(), + "同一邮箱最小发送间隔秒数(默认 60)") + ("max-verify-attempts", po::value(), + "验证码最大校验失败次数(默认 5)") + ("email-hourly-limit", po::value(), + "单邮箱每小时发送上限(默认 3)") + ("email-daily-limit", po::value(), + "单邮箱每日发送上限(默认 10)") + ("smtp-hourly-limit", po::value(), + "全局每小时发送上限/熔断(默认 100)") + ("default-subject", po::value(), + "默认邮件标题") + ("max-subject-len", po::value(), + "邮件标题长度上限(默认 256)") + ("max-content-len", po::value(), + "邮件正文长度上限(默认 4096)"); po::variables_map vm; po::store(po::parse_command_line(argc, argv, desc), vm); po::notify(vm); @@ -752,17 +700,31 @@ int main(int argc, char* argv[]) { return 0; } - // ── 配置加载:confs → env → 命令行 ── - std::string ini_text = fetch_config_from_confs(confs_host, confs_port); - merge_ini(g_config, ini_text); + // ── 配置加载:CLI > 环境变量 > 默认值(SMTP 账号经 .env 配置)── apply_env_overrides(g_config); - if (vm.count("addr")) g_config.host = vm["addr"].as(); - if (vm.count("port")) g_config.port = vm["port"].as(); - if (vm.count("workers")) g_config.workers = vm["workers"].as(); + if (vm.count("addr")) g_config.host = vm["addr"].as(); + if (vm.count("port")) g_config.port = vm["port"].as(); + if (vm.count("workers")) g_config.workers = vm["workers"].as(); + if (vm.count("smtp-port")) g_config.smtp_port = vm["smtp-port"].as(); + if (vm.count("code-len")) g_config.code_len = vm["code-len"].as(); + if (vm.count("code-ttl")) g_config.code_ttl = vm["code-ttl"].as(); + if (vm.count("send-interval")) g_config.send_interval = vm["send-interval"].as(); + if (vm.count("max-verify-attempts")) g_config.max_verify_attempts = vm["max-verify-attempts"].as(); + if (vm.count("email-hourly-limit")) g_config.email_hourly_limit = vm["email-hourly-limit"].as(); + if (vm.count("email-daily-limit")) g_config.email_daily_limit = vm["email-daily-limit"].as(); + if (vm.count("smtp-hourly-limit")) g_config.smtp_hourly_limit = vm["smtp-hourly-limit"].as(); + if (vm.count("default-subject")) g_config.default_subject = vm["default-subject"].as(); + if (vm.count("max-subject-len")) g_config.max_subject_len = vm["max-subject-len"].as(); + if (vm.count("max-content-len")) g_config.max_content_len = vm["max-content-len"].as(); validate_config(g_config); + // 日志级别 + std::string log_level = vm["log-level"].as(); + std::string instance = vm["instance"].as(); + g_log.set_level(log_level); + // 默认验证码有效期的文案 std::string default_content = "DNFS: 您的邮箱验证码为 {code}," + @@ -771,8 +733,8 @@ int main(int argc, char* argv[]) { std::string mail_from = g_config.mail_from.empty() ? g_config.smtp_user : g_config.mail_from; - std::cerr << "[smtp3] 配置来源: confs smtp.ini @ " - << confs_host << ":" << confs_port << std::endl; + std::cerr << "[smtp3] instance=" << instance << " log-level=" << log_level << std::endl; + std::cerr << "[smtp3] 配置来源: CLI > 环境变量 > 默认值(SMTP 账号经 .env 配置)" << std::endl; // ── 启动 RpcServer ── g_log.info("smtp3", "启动中... 监听 tcp://" + g_config.host + ":" + std::to_string(g_config.port)); From 3b6a0fb00254edd9ccd5b3c988bd765cdd0fbef0 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 09:56:41 +0000 Subject: [PATCH 042/196] =?UTF-8?q?captcha2:=20=E8=BF=81=E7=A7=BB=20captch?= =?UTF-8?q?a.ini=20=E8=87=B3=20CLI=20args=EF=BC=8C=E7=A7=BB=E9=99=A4=20con?= =?UTF-8?q?fs=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - captcha2/src/main.cpp: 移除 fetch_config_from_confs/merge_ini/RpcClient, 扩展 CLI 至 11 项(--instance/--log-level/--len/--ttl/--font-path/ --max-store/--rate-limit),配置优先级 CLI > env > 默认值 - core/services.yaml: captcha2 全部 8 项参数迁入 CLI args - confs/configs/captcha.ini: 已删除 - README.md: 移除 captcha.ini 表行 - _inst.sh: 新增 inst_module captcha2 --- README.md | 1 - _inst.sh | 1 + captcha2/src/main.cpp | 117 +++++++++++--------------------------- confs/configs/captcha.ini | 11 ---- core/services.yaml | 25 +++++++- 5 files changed, 56 insertions(+), 99 deletions(-) delete mode 100644 confs/configs/captcha.ini diff --git a/README.md b/README.md index a093eaf..7d27e36 100755 --- a/README.md +++ b/README.md @@ -209,7 +209,6 @@ |----------|----------|----------| | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | | `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | -| `captcha.ini` | captcha / captcha2 | confs 拉取 | | `example.ini` | —(旧 gateway 示例模板) | 无消费方 | - **部署安全约定**:`confs/configs/*.ini` 含敏感字段,权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可劫持对应服务(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args;原 `smtp.ini` 全部配置已随 smtp3 迁入 `core/services.yaml` 的 CLI args(SMTP 凭据仍经 `.env` 注入),防护对象相应转为 `services.yaml` 与 `.env` 的文件权限。 diff --git a/_inst.sh b/_inst.sh index de732f6..8e9f43b 100755 --- a/_inst.sh +++ b/_inst.sh @@ -42,6 +42,7 @@ inst_module "db2" inst_module "smtp" inst_module "smtp3" inst_module "captcha" +inst_module "captcha2" inst_module "confs" inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像) inst_module "aibox" # 需 ~/.cert/deepseek.key(见 _setenv.sh) diff --git a/captcha2/src/main.cpp b/captcha2/src/main.cpp index fcb808d..07d7a2b 100644 --- a/captcha2/src/main.cpp +++ b/captcha2/src/main.cpp @@ -10,7 +10,6 @@ #include #include -#include #include #include @@ -70,70 +69,7 @@ struct CaptchaConfig { int rate_limit_per_min = 600; // P1-1: 全局限流仅作最后防线,主要限流由 gateway 按 IP 负责 }; -// 从 confs 拉取 captcha.ini 全文,阻塞等待 confs 就绪 -static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port) { - pmc::net::RpcClient client(confs_host, confs_port, "captcha2", - /*timeout=*/5.0, /*retry_on_refused=*/true); - try { - auto data = client.call("get", {{"name", "captcha.ini"}}); - auto* content = data.as_object().if_contains("content"); - if (!content || !content->is_string()) { - std::cerr << "[captcha2] confs 返回的 captcha.ini 无 content 字段" << std::endl; - std::exit(1); - } - return json::value_to(*content); - } catch (const std::exception& e) { - std::cerr << "[captcha2] 致命错误: 从 confs 获取 captcha.ini 失败: " << e.what() << std::endl; - std::exit(1); - } -} - -// 把 ini 文本合并进 config(仅覆盖已知 section/key,白名单策略) -static void merge_ini(CaptchaConfig& cfg, const std::string& text) { - std::istringstream stream(text); - std::string line, section; - while (std::getline(stream, line)) { - // trim - auto start = line.find_first_not_of(" \t\r"); - if (start == std::string::npos || line[start] == '#' || line[start] == ';') - continue; - auto end = line.find_last_not_of(" \t\r"); - line = line.substr(start, end - start + 1); - - if (line.front() == '[' && line.back() == ']') { - section = line.substr(1, line.size() - 2); - continue; - } - auto eq = line.find('='); - if (eq == std::string::npos) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - // trim key - auto ke = key.find_last_not_of(" \t"); - if (ke != std::string::npos) key = key.substr(0, ke + 1); - auto ks = key.find_first_not_of(" \t"); - if (ks != std::string::npos) key = key.substr(ks); - // trim val - auto vs = val.find_first_not_of(" \t"); - if (vs != std::string::npos) val = val.substr(vs); - auto ve = val.find_last_not_of(" \t"); - if (ve != std::string::npos) val = val.substr(0, ve + 1); - - if (section == "server") { - if (key == "host") cfg.host = val; - else if (key == "port") cfg.port = parse_int(key, val); - else if (key == "workers") cfg.workers = parse_int(key, val); - } else if (section == "captcha") { - if (key == "len") cfg.len = parse_int(key, val); - else if (key == "ttl") cfg.ttl = parse_int(key, val); - else if (key == "font_path") cfg.font_path = val; - else if (key == "max_store") cfg.max_store = parse_int(key, val); - else if (key == "rate_limit_per_min") cfg.rate_limit_per_min = parse_int(key, val); - } - } -} - -// 环境变量覆盖(最高优先级,Docker 兼容) +// 环境变量覆盖(Docker 兼容;CLI > 环境变量 > 默认值) static void apply_env_overrides(CaptchaConfig& cfg) { if (auto* v = std::getenv("LISTEN_ADDR")) cfg.host = v; if (auto* v = std::getenv("LISTEN_PORT")) cfg.port = parse_int("LISTEN_PORT", v); @@ -530,20 +466,24 @@ static const auto _log_init = (LogC::instance().set_service_name("captcha2-log") int main(int argc, char* argv[]) { try { // ── 命令行参数 ── - std::string confs_host = "127.0.0.1"; - int confs_port = 8010; - { - const char* env; - if ((env = std::getenv("CONFS_HOST"))) confs_host = env; - if ((env = std::getenv("CONFS_PORT"))) confs_port = std::stoi(env); - } - po::options_description desc("captcha2 — pymsg 验证码服务"); desc.add_options() ("help,h", "显示帮助") - ("addr,a", po::value(), "监听地址(覆盖 confs)") - ("port,p", po::value(), "监听端口(覆盖 confs)") - ("workers,w", po::value(), "工作线程数(覆盖 confs)"); + ("instance", po::value()->default_value("captcha2"), + "实例名(注册中心用)") + ("log-level", po::value()->default_value("info"), + "日志级别(debug|info|warn|error)") + ("addr,a", po::value(), "监听地址") + ("port,p", po::value(), "监听端口") + ("workers,w", po::value(), "工作线程数") + ("len", po::value(), "验证码长度(2~8,默认 4)") + ("ttl", po::value(), "验证码有效期秒数(默认 300)") + ("font-path", po::value(), + "TrueType 字体路径") + ("max-store", po::value(), + "内存中最大待验证 token 数(默认 10000)") + ("rate-limit", po::value(), + "全局每分钟生成上限(默认 600)"); po::variables_map vm; po::store(po::parse_command_line(argc, argv, desc), vm); po::notify(vm); @@ -553,20 +493,27 @@ int main(int argc, char* argv[]) { return 0; } - // ── 配置加载:confs → env → 命令行 ── - std::string ini_text = fetch_config_from_confs(confs_host, confs_port); - merge_ini(g_config, ini_text); - + // ── 配置加载:CLI > 环境变量 > 默认值 ── apply_env_overrides(g_config); - if (vm.count("addr")) g_config.host = vm["addr"].as(); - if (vm.count("port")) g_config.port = vm["port"].as(); - if (vm.count("workers")) g_config.workers = vm["workers"].as(); + if (vm.count("addr")) g_config.host = vm["addr"].as(); + if (vm.count("port")) g_config.port = vm["port"].as(); + if (vm.count("workers")) g_config.workers = vm["workers"].as(); + if (vm.count("len")) g_config.len = vm["len"].as(); + if (vm.count("ttl")) g_config.ttl = vm["ttl"].as(); + if (vm.count("font-path")) g_config.font_path = vm["font-path"].as(); + if (vm.count("max-store")) g_config.max_store = vm["max-store"].as(); + if (vm.count("rate-limit")) g_config.rate_limit_per_min = vm["rate-limit"].as(); validate_config(g_config); - std::cerr << "[captcha2] 配置来源: confs captcha.ini @ " - << confs_host << ":" << confs_port << std::endl; + // 日志级别 + std::string log_level = vm["log-level"].as(); + std::string instance = vm["instance"].as(); + g_log.set_level(log_level); + + std::cerr << "[captcha2] instance=" << instance << " log-level=" << log_level << std::endl; + std::cerr << "[captcha2] 配置来源: CLI > 环境变量 > 默认值" << std::endl; std::cerr << "[captcha2] captcha_len=" << g_config.len << " ttl=" << g_config.ttl << " max_store=" << g_config.max_store diff --git a/confs/configs/captcha.ini b/confs/configs/captcha.ini deleted file mode 100644 index d1a76af..0000000 --- a/confs/configs/captcha.ini +++ /dev/null @@ -1,11 +0,0 @@ -[server] -host = 127.0.0.1 -port = 8009 -workers = 4 - -[captcha] -len = 4 -ttl = 300 -font_path = /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf -max_store = 10000 -rate_limit_per_min = 600 # 全局限流仅作最后防线,主要限流由 gateway 按 IP 负责 diff --git a/core/services.yaml b/core/services.yaml index d86fe0f..69e01a4 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -145,9 +145,30 @@ services: # --- 验证码 --- - name: captcha2 cmd: bash /opt/micro_servers/captcha2/_run.sh - args: ["--instance", "captcha2", "--log-level", "info"] + args: + - "--instance" + - "captcha2" + - "--log-level" + - "info" + # --- 配置全部经 CLI 传递(原 confs/configs/captcha.ini 迁入) --- + - "--addr" + - "127.0.0.1" + - "--port" + - "8009" + - "--workers" + - "4" + - "--len" + - "4" + - "--ttl" + - "300" + - "--font-path" + - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + - "--max-store" + - "10000" + - "--rate-limit" + - "600" depends_on: [] - sleep_before: 1 + sleep_before: 0 # --- AI 工具箱 --- - name: aibox From c9e8d04815b3389ab8da59cabd7c5240f8a6f57f Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 10:08:29 +0000 Subject: [PATCH 043/196] =?UTF-8?q?fsys:=20=E8=BF=81=E7=A7=BB=20static.ini?= =?UTF-8?q?=20=E8=87=B3=20CLI=20args=EF=BC=8C=E7=A7=BB=E9=99=A4=20confs=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fsys/src/fsys.cpp: 移除 parse_ini/IniMap/confs RPC 调用,改为 boost::program_options 14 项 CLI 参数,配置优先级 CLI > env > 默认值 - fsys/Makefile: 添加 -lboost_program_options - core/services.yaml: fsys 全部参数迁入 CLI args - confs/configs/static.ini: 已删除 - README.md: 更新 fsys 配置说明,移除 static.ini 表行,更新安全约定 --- README.md | 5 +- confs/configs/static.ini | 17 --- core/services.yaml | 29 ++++- fsys/Makefile | 2 +- fsys/src/fsys.cpp | 239 +++++++++++---------------------------- 5 files changed, 94 insertions(+), 198 deletions(-) delete mode 100644 confs/configs/static.ini diff --git a/README.md b/README.md index 7d27e36..21bc73d 100755 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ - **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。 - 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。 - 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。 -- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);confs 为**强依赖**,拉取失败直接退出,无本地回退。 +- 配置全部经 CLI 传递(CLI > 环境变量 > 默认值,原 `confs/configs/static.ini` 迁入 `core/services.yaml` 的 fsys 条目 args),不依赖 confs。 ### dnfs(树状菜单服务) @@ -208,10 +208,9 @@ | 配置文件 | 消费模块 | 依赖强度 | |----------|----------|----------| | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | -| `static.ini` | static(fsys.py)/ fsys | 强依赖(拉取失败直接退出) | | `example.ini` | —(旧 gateway 示例模板) | 无消费方 | -- **部署安全约定**:`confs/configs/*.ini` 含敏感字段,权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可劫持对应服务(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args;原 `smtp.ini` 全部配置已随 smtp3 迁入 `core/services.yaml` 的 CLI args(SMTP 凭据仍经 `.env` 注入),防护对象相应转为 `services.yaml` 与 `.env` 的文件权限。 +- **部署安全约定**:`confs/configs/*.ini` 含敏感字段,权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可劫持对应服务(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args;原 `smtp.ini` 全部配置已随 smtp3 迁入 `core/services.yaml` 的 CLI args(SMTP 凭据仍经 `.env` 注入);原 `captcha.ini` 与 `static.ini` 已分别随 captcha2、fsys 迁入 CLI args,防护对象相应转为 `services.yaml` 与 `.env` 的文件权限。 ### aibox(独立 AI 智能体系统) diff --git a/confs/configs/static.ini b/confs/configs/static.ini deleted file mode 100644 index fbccc07..0000000 --- a/confs/configs/static.ini +++ /dev/null @@ -1,17 +0,0 @@ -[server] -host = 127.0.0.1 -port = 9200 - -[directory] -root = ./public - -[ssl] -enabled = false -cert_file = cert.pem -key_file = key.pem - -[cors] -# 允许跨域的 Origin 白名单(逗号分隔) -# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 * -# 本地开发联调时可临时追加 http://localhost:9200,调试完务必移除 -allowed_origins = https://qsont.me,https://yhdet.top diff --git a/core/services.yaml b/core/services.yaml index 69e01a4..03f839c 100644 --- a/core/services.yaml +++ b/core/services.yaml @@ -83,7 +83,34 @@ services: # --- 前端静态资源 --- - name: fsys cmd: bash /opt/micro_servers/fsys/_run.sh - args: ["--instance", "fsys", "--log-level", "info"] + args: + - "--instance" + - "fsys" + - "--log-level" + - "info" + # --- 配置全部经 CLI 传递(原 confs/configs/static.ini 迁入) --- + - "--addr" + - "127.0.0.1" + - "--port" + - "9200" + - "--threads" + - "4" + - "--root" + - "./public" + - "--ssl-enabled" + - "false" + - "--cert-file" + - "cert.pem" + - "--key-file" + - "key.pem" + - "--cors-origins" + - "https://qsont.me,https://yhdet.top" + - "--db-host" + - "127.0.0.1" + - "--db-port" + - "8005" + - "--dnfs-url" + - "http://127.0.0.1:8006" depends_on: [] sleep_before: 0 diff --git a/fsys/Makefile b/fsys/Makefile index 1420b80..9f76753 100644 --- a/fsys/Makefile +++ b/fsys/Makefile @@ -2,7 +2,7 @@ CPP = g++ CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe INCS = -I ../libhttp/inc -I ../libmsg/inc -I /opt/homebrew/include LIBS = -L ../libhttp/build -L ../libmsg/build -L /opt/homebrew/lib \ - -lus_http -lus_httpclient -lus_msg -lboost_json \ + -lus_http -lus_httpclient -lus_msg -lboost_json -lboost_program_options \ -lssl -lcrypto -lcurl -lpthread RPATH = -Wl,-rpath,'$$ORIGIN/../../libhttp/build' \ -Wl,-rpath,'$$ORIGIN/../../libmsg/build' \ diff --git a/fsys/src/fsys.cpp b/fsys/src/fsys.cpp index b8723cf..36c004a 100644 --- a/fsys/src/fsys.cpp +++ b/fsys/src/fsys.cpp @@ -14,11 +14,11 @@ * 安全改进(相比 Python static): * F-1 server.setMaxBodySize(10MB) 限制请求体大小 * F-2 libhttp 原生 HTTPS 支持,无需回退开发服务器 - * F-3 命令行参数 --host/--addr/-a --port/-p --threads/-t --log-level --instance --config --help + * F-3 命令行参数 --addr/-a --port/-p --threads/-t * F-4 所有端口/线程数有严格边界校验 * F-5 Boost.Beast 异步多线程替代 Flask 同步 * F-6 消除 Python/Flask/waitress 依赖 - * F-7 日志客户端复用主配置(confs 拉取的 db host/port) + * F-7 日志客户端复用主配置(CLI 传递的 db host/port) * * 依赖: libhttp (HttpServer/HttpClient), libmsg (RpcClient/AsyncQueue), * boost::json, OpenSSL, libcurl @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -64,6 +65,7 @@ #include namespace json = boost::json; +namespace po = boost::program_options; namespace http = boost::beast::http; namespace fs = std::filesystem; using namespace pmc::net; @@ -209,44 +211,6 @@ static std::string extract_path(const std::string& target) { return (q == std::string::npos) ? target : target.substr(0, q); } -// ============================================================================ -// INI 解析(白名单模式,与 dnfs2 一致) -// ============================================================================ - -using IniMap = std::map>; - -static IniMap parse_ini(const std::string& text) { - IniMap result; - std::string section; - std::istringstream ss(text); - std::string line; - while (std::getline(ss, line)) { - auto b = line.find_first_not_of(" \t\r\n"); - if (b == std::string::npos) continue; - line = line.substr(b); - auto e = line.find_last_not_of(" \t\r\n"); - if (e != std::string::npos) line = line.substr(0, e + 1); - - if (line.empty() || line[0] == '#' || line[0] == ';') continue; - if (line.front() == '[' && line.back() == ']') { - section = line.substr(1, line.size() - 2); - continue; - } - auto eq = line.find('='); - if (eq == std::string::npos || section.empty()) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - auto trim = [](std::string& s) { - auto bb = s.find_first_not_of(" \t"); - auto ee = s.find_last_not_of(" \t"); - s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1); - }; - trim(key); trim(val); - result[section][key] = val; - } - return result; -} - // ============================================================================ // 配置 // ============================================================================ @@ -264,105 +228,43 @@ struct Config { std::vector cors_origins; - std::string confs_host = "127.0.0.1"; - int confs_port = 8010; - std::string db_host = "127.0.0.1"; int db_port = 8005; std::string dnfs_url = "http://127.0.0.1:8006"; - - // CLI 标准化参数(Step 3) - std::string log_level = "info"; // debug|info|warn|error - std::string instance_name = "fsys"; // 实例名(注册中心用) - std::string confs_config_name = "static.ini"; // confs 拉取的配置名 }; static Config load_config(int argc, char* argv[]) { Config cfg; - // 0. 预扫描 --help 和 --config(需在 confs 拉取前处理) - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; - if (arg == "--help" || arg == "-h") { - std::printf( - "fsys — C++20 静态文件托管服务\n\n" - "Usage: fsys [OPTIONS]\n\n" - "Options:\n" - " -h, --help Show this help and exit\n" - " -a, --addr Listen address (alias: --host)\n" - " --host Listen address (canonical name)\n" - " -p, --port Listen port\n" - " -t, --threads Worker threads\n" - " --config confs config name (default: static.ini)\n" - " --log-level debug|info|warn|error (default: info)\n" - " --instance Instance name (default: fsys)\n\n" - "Config priority: CLI > env > confs > defaults\n"); - std::exit(0); - } - if (arg == "--config" && i + 1 < argc) { - cfg.confs_config_name = argv[++i]; - } + // ── 命令行参数 ── + po::options_description desc("fsys — 静态文件托管服务"); + desc.add_options() + ("help,h", "显示帮助") + ("instance", po::value()->default_value("fsys"), "实例名") + ("log-level", po::value()->default_value("info"), "日志级别") + ("addr,a", po::value(), "监听地址") + ("port,p", po::value(), "监听端口") + ("threads,t", po::value(), "工作线程数") + ("root", po::value(), "静态文件根目录") + ("ssl-enabled", po::value(), "SSL 开关 (true/false)") + ("cert-file", po::value(), "SSL 证书路径") + ("key-file", po::value(), "SSL 私钥路径") + ("cors-origins", po::value(), "CORS 白名单(逗号分隔)") + ("db-host", po::value(), "数据库 RPC 地址") + ("db-port", po::value(), "数据库 RPC 端口") + ("dnfs-url", po::value(), "dnfs2 服务 URL"); + po::variables_map vm; + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + + if (vm.count("help")) { + std::cout << desc << std::endl; + std::exit(0); } - // 1. confs 引导地址 - if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h; - if (const char* p = std::getenv("CONFS_PORT")) { - cfg.confs_port = safe_stoi(p, "CONFS_PORT"); - if (cfg.confs_port < 1 || cfg.confs_port > 65535) { - std::fprintf(stderr, "[fsys] invalid CONFS_PORT: %d (must be 1-65535)\n", cfg.confs_port); - std::exit(1); - } - } - - // 2. 从 confs 拉取配置(retry_on_refused=true) - std::string ini_text; - try { - RpcClient confs_client(cfg.confs_host, cfg.confs_port, "fsys", - /*timeout=*/5.0, /*retry_on_refused=*/true); - json::value data = confs_client.call("get", {{"name", cfg.confs_config_name}}); - if (data.is_object() && data.as_object().contains("content")) { - ini_text = std::string(data.as_object().at("content").as_string()); - } - } catch (const std::exception& e) { - std::fprintf(stderr, "[fsys] 从 confs 获取 %s 失败: %s\n", - cfg.confs_config_name.c_str(), e.what()); - std::exit(1); - } - - // 3. 解析 INI - IniMap ini = parse_ini(ini_text); - - auto get = [&](const std::string& sec, const std::string& key, - const std::string& def = "") -> std::string { - auto si = ini.find(sec); - if (si == ini.end()) return def; - auto ki = si->second.find(key); - return (ki == si->second.end()) ? def : ki->second; - }; - - cfg.listen_host = get("server", "host", cfg.listen_host); - cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port"); - cfg.static_root = get("directory", "root", cfg.static_root); - - std::string ssl_str = get("ssl", "enabled", "false"); - cfg.ssl_enabled = (ssl_str == "true" || ssl_str == "True" || ssl_str == "1"); - cfg.ssl_cert = get("ssl", "cert_file", cfg.ssl_cert); - cfg.ssl_key = get("ssl", "key_file", cfg.ssl_key); - - std::string origins_str = get("cors", "allowed_origins", ""); - if (!origins_str.empty()) { - std::istringstream oss(origins_str); - std::string origin; - while (std::getline(oss, origin, ',')) { - auto b = origin.find_first_not_of(" \t"); - auto e = origin.find_last_not_of(" \t"); - if (b != std::string::npos) - cfg.cors_origins.push_back(origin.substr(b, e - b + 1)); - } - } - - // 4. 环境变量覆盖(Docker 兼容) + // ── 配置加载:CLI > 环境变量 > 默认值 ── + // 1. 环境变量覆盖(Docker 兼容) if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v; if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT"); if (const char* v = std::getenv("STATIC_ROOT")) cfg.static_root = v; @@ -370,32 +272,49 @@ static Config load_config(int argc, char* argv[]) { if (const char* v = std::getenv("DB_PORT")) cfg.db_port = safe_stoi(v, "DB_PORT"); if (const char* v = std::getenv("DNFS_URL")) cfg.dnfs_url = v; - // 5. 命令行参数(最高优先级) - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; - if ((arg == "--addr" || arg == "-a" || arg == "--host") && i + 1 < argc) cfg.listen_host = argv[++i]; - else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port"); - else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads"); - else if (arg == "--log-level" && i + 1 < argc) cfg.log_level = argv[++i]; - else if (arg == "--instance" && i + 1 < argc) cfg.instance_name = argv[++i]; - // --config 和 --help 已在预扫描阶段处理 + // 2. CLI 参数覆盖(最高优先级) + if (vm.count("addr")) cfg.listen_host = vm["addr"].as(); + if (vm.count("port")) cfg.listen_port = vm["port"].as(); + if (vm.count("threads")) cfg.threads = vm["threads"].as(); + if (vm.count("root")) cfg.static_root = vm["root"].as(); + if (vm.count("ssl-enabled")) { + std::string s = vm["ssl-enabled"].as(); + cfg.ssl_enabled = (s == "true" || s == "True" || s == "1"); } + if (vm.count("cert-file")) cfg.ssl_cert = vm["cert-file"].as(); + if (vm.count("key-file")) cfg.ssl_key = vm["key-file"].as(); + if (vm.count("cors-origins")) { + std::string origins_str = vm["cors-origins"].as(); + if (!origins_str.empty()) { + std::istringstream oss(origins_str); + std::string origin; + while (std::getline(oss, origin, ',')) { + auto b = origin.find_first_not_of(" \t"); + auto e = origin.find_last_not_of(" \t"); + if (b != std::string::npos) + cfg.cors_origins.push_back(origin.substr(b, e - b + 1)); + } + } + } + if (vm.count("db-host")) cfg.db_host = vm["db-host"].as(); + if (vm.count("db-port")) cfg.db_port = vm["db-port"].as(); + if (vm.count("dnfs-url")) cfg.dnfs_url = vm["dnfs-url"].as(); - // 6. 校验必需配置项 + // 3. 校验必需配置项 if (cfg.listen_host.empty()) { - std::fprintf(stderr, "[fsys] 缺少必需配置: [server] host\n"); + std::fprintf(stderr, "[fsys] 缺少必需配置: --addr\n"); std::exit(1); } if (cfg.static_root.empty()) { - std::fprintf(stderr, "[fsys] 缺少必需配置: [directory] root\n"); + std::fprintf(stderr, "[fsys] 缺少必需配置: --root\n"); std::exit(1); } if (cfg.cors_origins.empty()) { - std::fprintf(stderr, "[fsys] 缺少必需配置: [cors] allowed_origins\n"); + std::fprintf(stderr, "[fsys] 缺少必需配置: --cors-origins\n"); std::exit(1); } - // 7. 数值范围校验 + // 4. 数值范围校验 if (cfg.listen_port < 1 || cfg.listen_port > 65535) { std::fprintf(stderr, "[fsys] invalid listen_port: %d (must be 1-65535)\n", cfg.listen_port); std::exit(1); @@ -405,19 +324,7 @@ static Config load_config(int argc, char* argv[]) { std::exit(1); } - // log_level 校验 - { - std::string ll = cfg.log_level; - std::transform(ll.begin(), ll.end(), ll.begin(), ::tolower); - if (ll != "debug" && ll != "info" && ll != "warn" && ll != "error") { - std::fprintf(stderr, "[fsys] invalid --log-level: %s (must be debug|info|warn|error)\n", - cfg.log_level.c_str()); - std::exit(1); - } - cfg.log_level = ll; - } - - // 8. 路径规范化:static_root/SSL 相对路径基于当前工作目录 + // 5. 路径规范化:static_root/SSL 相对路径基于当前工作目录 // (_run.sh 以模块目录为 cwd 启动,与 Python 版 BASE_DIR 语义一致; // Docker 场景使用绝对路径 STATIC_ROOT,不受影响) if (!cfg.static_root.empty() && cfg.static_root[0] != '/') { @@ -699,11 +606,7 @@ public: _filesystem_enabled = false; } - std::printf("[fsys] 配置来源: confs (%s @ %s:%d)\n", - _cfg.confs_config_name.c_str(), - _cfg.confs_host.c_str(), _cfg.confs_port); - std::printf("[fsys] 实例: %s | 日志级别: %s\n", - _cfg.instance_name.c_str(), _cfg.log_level.c_str()); + std::printf("[fsys] 配置来源: CLI > 环境变量 > 默认值\n"); std::printf("[fsys] 静态目录: %s\n", _cfg.static_root.c_str()); } @@ -735,19 +638,6 @@ private: void _log(const std::string& level, const std::string& service, const std::string& message, const std::string& trace_id = "", const std::string& extra = "") { - // 日志级别过滤 - static const std::unordered_map kLogLevels = { - {"debug", 0}, {"DEBUG", 0}, - {"info", 1}, {"INFO", 1}, - {"warn", 2}, {"WARNING", 2}, - {"error", 3}, {"ERROR", 3} - }; - auto cfg_it = kLogLevels.find(_cfg.log_level); - auto msg_it = kLogLevels.find(level); - if (cfg_it != kLogLevels.end() && msg_it != kLogLevels.end()) { - if (msg_it->second < cfg_it->second) return; - } - LogEntry e; e.ts = utc_now(); e.service = service; @@ -1637,9 +1527,6 @@ int main(int argc, char* argv[]) { cfg.static_root.c_str(), cfg.ssl_enabled ? "on" : "off"); std::printf("[fsys] db=%s:%d dnfs=%s\n", cfg.db_host.c_str(), cfg.db_port, cfg.dnfs_url.c_str()); - std::printf("[fsys] instance=%s log-level=%s config=%s\n", - cfg.instance_name.c_str(), cfg.log_level.c_str(), - cfg.confs_config_name.c_str()); std::fflush(stdout); FsysServer server(std::move(cfg)); From 42b4a89ed33890cea0a430b35cda2d494592c379 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 10:09:58 +0000 Subject: [PATCH 044/196] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=AE=8B=E7=95=99=20?= =?UTF-8?q?ini=EF=BC=9A=E5=88=A0=E9=99=A4=20db.ini/redirect80.ini/example.?= =?UTF-8?q?ini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.ini/redirect80.ini:对应服务已迁入 CLI args,ini 为残留 - example.ini:旧 gateway 示例模板,无消费方 - README.md:移除 example.ini 表行 --- README.md | 1 - confs/configs/db.ini | 16 ---------------- confs/configs/example.ini | 10 ---------- confs/configs/redirect80.ini | 5 ----- 4 files changed, 32 deletions(-) delete mode 100644 confs/configs/db.ini delete mode 100644 confs/configs/example.ini delete mode 100644 confs/configs/redirect80.ini diff --git a/README.md b/README.md index 21bc73d..6ce7500 100755 --- a/README.md +++ b/README.md @@ -208,7 +208,6 @@ | 配置文件 | 消费模块 | 依赖强度 | |----------|----------|----------| | `gateway.ini` | gateway | confs 拉取(0.7.0 起配置完全迁入) | -| `example.ini` | —(旧 gateway 示例模板) | 无消费方 | - **部署安全约定**:`confs/configs/*.ini` 含敏感字段,权限必须收紧为 `root:root 0640`,仅 root 可写;被同机普通用户篡改可劫持对应服务(详见 `docs/security/core_SECURITY_AUDIT_20260801.md` 2026-08-10 复审)。原 `ca.ini` 管理员白名单已随 ca v1.1.0 迁入 `core/services.yaml` 的 CLI args;原 `smtp.ini` 全部配置已随 smtp3 迁入 `core/services.yaml` 的 CLI args(SMTP 凭据仍经 `.env` 注入);原 `captcha.ini` 与 `static.ini` 已分别随 captcha2、fsys 迁入 CLI args,防护对象相应转为 `services.yaml` 与 `.env` 的文件权限。 diff --git a/confs/configs/db.ini b/confs/configs/db.ini deleted file mode 100644 index 595ce8b..0000000 --- a/confs/configs/db.ini +++ /dev/null @@ -1,16 +0,0 @@ -[server] -host = 127.0.0.1 -port = 8005 -workers = 4 - -[database] -url = sqlite:///database.db - -[logs] -retention_days = 30 -max_records = 1000000 -cleanup_interval = 3600 - -[confs] -host = 127.0.0.1 -port = 8010 diff --git a/confs/configs/example.ini b/confs/configs/example.ini deleted file mode 100644 index cbb1dd4..0000000 --- a/confs/configs/example.ini +++ /dev/null @@ -1,10 +0,0 @@ -# 示例配置 —— 统一配置服务 confs 的配置文件存放目录 -# 各微服务的配置文件集中放在本目录,由 confs 通过 pymsg get 路由分发给内部服务。 -# 支持类型:.ini / .conf / .cfg / .json / .yaml / .yml / .toml / .txt - -[Server] -address = 127.0.0.1 -port = 8010 - -[Feature] -dark_mode = on diff --git a/confs/configs/redirect80.ini b/confs/configs/redirect80.ini deleted file mode 100644 index 8f122c3..0000000 --- a/confs/configs/redirect80.ini +++ /dev/null @@ -1,5 +0,0 @@ -[server] -host = 0.0.0.0 -port = 80 -redirect_port = 443 -redirect_code = 301 From 75897c68eb5380961ff46fe0eee730607f90db88 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 10 Aug 2026 10:39:03 +0000 Subject: [PATCH 045/196] =?UTF-8?q?dnfs2:=20=E7=89=B9=E6=AE=8A=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=96=B0=E5=A2=9E=20style/theme=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=95=8C=E9=9D=A2=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E4=B8=8E=E4=B8=BB=E9=A2=98=E6=8D=A2=E8=82=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dnfs2/src/dnfs2.cpp | 59 ++++++++++++- fsys/public/dnfs.html | 3 + fsys/public/js/dnfs.js | 184 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 244 insertions(+), 2 deletions(-) diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp index ee67d5d..37f4526 100644 --- a/dnfs2/src/dnfs2.cpp +++ b/dnfs2/src/dnfs2.cpp @@ -60,6 +60,10 @@ static constexpr size_t kSpMaxUrl = 2048; static constexpr size_t kSpMaxCode = 512 * 1024; static constexpr size_t kSpMaxData = 3500 * 1024; static constexpr size_t kSpReviewCodePrefix = 4000; +static constexpr size_t kSpMaxCss = 256 * 1024; // style 节点 CSS 上限 +static constexpr size_t kSpMaxThemeVars = 64; // theme 节点变量条数上限 +static constexpr size_t kSpMaxThemeKey = 64; +static constexpr size_t kSpMaxThemeValue = 256; // 日志 static constexpr size_t kLogMaxMessage = 8192; @@ -239,7 +243,7 @@ private: // 特殊节点校验 // ============================================================================ -static const std::vector kSpTypes = {"link", "js", "image", "file"}; +static const std::vector kSpTypes = {"link", "js", "image", "file", "style", "theme"}; // 识别特殊节点信封:合法信封返回 object;普通内容返回 nullptr; // 以 {"_sp" 开头但 JSON 非法视为格式错误(抛异常)。 @@ -271,6 +275,10 @@ static json::value parse_sp(const std::string& content) { static void validate_sp(const json::object& sp) { std::string t = std::string(sp.at("_sp").as_string()); + // persist(style/theme 的生命周期标记)可选,必须是布尔值 + if (sp.contains("persist") && !sp.at("persist").is_bool()) + throw DnfsExp("persist 必须是布尔值", 400); + if (t == "link") { if (!sp.contains("url") || !sp.at("url").is_string()) throw DnfsExp("链接节点缺少 url", 400); @@ -310,6 +318,46 @@ static void validate_sp(const json::object& sp) { throw DnfsExp("文件节点缺少数据", 400); if (data.size() > kSpMaxData) throw DnfsExp("文件过大(上限约 2.6MB)", 400); + } else if (t == "style") { + if (!sp.contains("css") || !sp.at("css").is_string()) + throw DnfsExp("样式节点缺少 css", 400); + std::string css = std::string(sp.at("css").as_string()); + if (css.empty() || css.find_first_not_of(" \t\r\n") == std::string::npos) + throw DnfsExp("样式节点 css 不能为空", 400); + if (css.size() > kSpMaxCss) + throw DnfsExp("样式节点 css 过大(上限 256KB)", 400); + } else if (t == "theme") { + if (!sp.contains("vars") || !sp.at("vars").is_object()) + throw DnfsExp("主题节点缺少 vars 对象", 400); + const auto& vars = sp.at("vars").as_object(); + if (vars.empty()) + throw DnfsExp("主题节点 vars 不能为空", 400); + if (vars.size() > kSpMaxThemeVars) + throw DnfsExp("主题变量过多(上限 64 条)", 400); + auto is_name_char = [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + }; + for (const auto& kv : vars) { + std::string key(kv.key()); + // 仅允许 CSS 自定义属性名:-- 前缀 + [A-Za-z0-9_-] + if (key.size() < 3 || key.size() > kSpMaxThemeKey + || key[0] != '-' || key[1] != '-') + throw DnfsExp("主题变量名必须以 -- 开头", 400); + for (char c : key) { + if (!is_name_char(c)) + throw DnfsExp("主题变量名含非法字符", 400); + } + if (!kv.value().is_string()) + throw DnfsExp("主题变量值必须是字符串", 400); + std::string val = std::string(kv.value().as_string()); + if (val.empty() || val.size() > kSpMaxThemeValue) + throw DnfsExp("主题变量值为空或过长(上限 256 字符)", 400); + // 值禁止 {}<>:防止逃逸 :root 变量上下文注入任意规则, + // 保持 theme 相对 style 的"仅换肤"克制定位 + if (val.find_first_of("{}<>") != std::string::npos) + throw DnfsExp("主题变量值含非法字符({}<>)", 400); + } } } @@ -333,6 +381,15 @@ static std::string sp_review_text(const std::string& title, const json::object& if (code.size() > kSpReviewCodePrefix) code = code.substr(0, kSpReviewCodePrefix); parts.push_back(code); } + } else if (t == "style") { + if (sp.contains("css") && sp.at("css").is_string()) { + std::string css = std::string(sp.at("css").as_string()); + if (css.size() > kSpReviewCodePrefix) css = css.substr(0, kSpReviewCodePrefix); + parts.push_back(css); + } + } else if (t == "theme") { + if (sp.contains("vars") && sp.at("vars").is_object()) + parts.push_back(json::serialize(sp.at("vars"))); } else { if (sp.contains("name") && sp.at("name").is_string()) parts.push_back(std::string(sp.at("name").as_string())); diff --git a/fsys/public/dnfs.html b/fsys/public/dnfs.html index 861e540..75927e2 100644 --- a/fsys/public/dnfs.html +++ b/fsys/public/dnfs.html @@ -35,9 +35,12 @@ + + +
diff --git a/fsys/public/js/dnfs.js b/fsys/public/js/dnfs.js index 43cc014..974c9b5 100644 --- a/fsys/public/js/dnfs.js +++ b/fsys/public/js/dnfs.js @@ -445,7 +445,7 @@ function parseSp(content) { } catch (e) { return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) } - if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; + if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file', 'style', 'theme'].indexOf(obj._sp) !== -1) return obj; return null; } @@ -468,6 +468,7 @@ function renderContent(container, item) { else if (sp._sp === 'js') renderSpJs(container, sp, appendNote); else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); + else if (sp._sp === 'style' || sp._sp === 'theme') renderSpCss(container, sp, appendNote, item); } /* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */ @@ -505,8 +506,15 @@ var sp_effects_disabled = (function() { try { return localStorage.getItem('sp_effects_disabled') === '1'; } catch (e) { return false; } })(); /* 清除当前页面已运行的全部特效沙箱(进入新节点 / 关闭特效时调用) */ +/* 清除当前页面已运行的全部特效沙箱与节点作用域注入样式(进入新节点 / 关闭特效时调用)。 + * persist 注入样式(data-persist="1")跨节点保留,由用户面板"还原界面样式"统一清除。 */ function clearAllEffects() { document.querySelectorAll('.sp-effect-frame').forEach(function(f) { f.remove(); }); + document.querySelectorAll('.sp-injected-style:not([data-persist="1"])').forEach(function(s) { s.remove(); }); +} +/* 还原界面样式:清除 style/theme 节点的全部注入样式(含 persist),不清特效沙箱 */ +function clearAllStyles() { + document.querySelectorAll('.sp-injected-style').forEach(function(s) { s.remove(); }); } function renderSpJs(container, sp, appendNote) { var bar = document.createElement('div'); @@ -619,6 +627,108 @@ function renderSpFile(container, sp, appendNote) { appendNote(); } +/* ===== 样式类节点(style/theme):CSS 注入