From 35e8d747e159e7e908acbb92b369aaf348dc65cd Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sun, 9 Aug 2026 06:44:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20smtp3=20C++20=20=E9=87=8D=E5=86=99=20+?= =?UTF-8?q?=20=E5=AE=89=E5=85=A8=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20(P0?= =?UTF-8?q?=C3=972/P1-2/P2/P3=20=E5=85=A8=E9=83=A8=E5=B7=B2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 smtp3: pymsg/TCP :8008 邮件验证码服务 (smtp_v2.py 重写, 零新增依赖) - 审计实证并修复 2 项 P0: SMTP 响应解析颠倒 (发送 100% 失败)、base64 符号扩展 - 修复 P1-2 SIGPIPE、P3-1 日志注入脱敏、P3-3 IPv6、P3-4 模偏差、P3-5 LogC 长连接复用 - 剩余 P1-1 (TLS 证书校验) 与端到端发信验证待 smtp.ini 凭据就绪 - 新增审计报告 docs/security/smtp3_SECURITY_AUDIT_20260809.md 并更新 INDEX --- confs/configs/smtp.ini | 19 + docs/INDEX.md | 6 +- docs/reports/smtp_cpp_rewrite_dev_doc.md | 739 ++++++++++++ .../security/smtp3_SECURITY_AUDIT_20260809.md | 269 +++++ smtp3/Makefile | 20 + smtp3/_run.sh | 4 + smtp3/_set.sh | 7 + smtp3/inc/LogC.hpp | 124 ++ smtp3/src/main.cpp | 1013 +++++++++++++++++ 9 files changed, 2199 insertions(+), 2 deletions(-) create mode 100644 confs/configs/smtp.ini create mode 100644 docs/reports/smtp_cpp_rewrite_dev_doc.md create mode 100644 docs/security/smtp3_SECURITY_AUDIT_20260809.md create mode 100644 smtp3/Makefile create mode 100755 smtp3/_run.sh create mode 100755 smtp3/_set.sh create mode 100644 smtp3/inc/LogC.hpp create mode 100644 smtp3/src/main.cpp diff --git a/confs/configs/smtp.ini b/confs/configs/smtp.ini new file mode 100644 index 0000000..8d946d3 --- /dev/null +++ b/confs/configs/smtp.ini @@ -0,0 +1,19 @@ +[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/docs/INDEX.md b/docs/INDEX.md index 3c531ac..60ff7b3 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,9 +7,9 @@ ``` docs/ ├── INDEX.md # 本文件(文档总索引) -├── security/ # 安全审计与加固(27 篇) +├── security/ # 安全审计与加固(28 篇) ├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(10 篇) +└── reports/ # 分析报告与开发规划(11 篇) ``` --- @@ -49,6 +49,7 @@ docs/ | 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 统一配置服务 | @@ -88,6 +89,7 @@ docs/ | [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 项遗留一并在重写中修复,零新增系统依赖) | --- diff --git a/docs/reports/smtp_cpp_rewrite_dev_doc.md b/docs/reports/smtp_cpp_rewrite_dev_doc.md new file mode 100644 index 0000000..044c7bd --- /dev/null +++ b/docs/reports/smtp_cpp_rewrite_dev_doc.md @@ -0,0 +1,739 @@ +# smtp 模块 C++20 重写 · 开发方案 + +> 文档类型:模块重写开发计划 +> 模块:smtp(邮箱验证码服务) +> 日期:2026-08-09 +> 参照:captcha2 / db2 / dnfs2 的 Python → C++20 重写路径 + +--- + +## 1. 概述 + +### 1.1 背景与动机 + +smtp 模块是 micro_servers 中仅存的少量 Python 内部基础服务之一(smtp + captcha + ai_review),负责注册/重置密码流程的邮箱验证码发送与校验。当前 Python 版(`smtp_v2.py`,317 行)依赖仅 `pymsg` + Python 标准库 `smtplib`/`email`,属于**中等规模、外部 I/O 密集**的重写目标。 + +| 维度 | Python 版现状 | 重写收益 | +|------|-------------|----------| +| 依赖 | Python venv + pymsg 本地包 | 消除 Python 运行时,编译为单二进制 `smtp2.bin` | +| 启动 | `_run.sh` 激活 venv 后 `exec python3` | 与 db2/dnfs2/captcha2 一致的直接启动 | +| 安全遗留 | P1-F1 now 遮蔽 → 回滚失效、P2-F2 验证码明文入日志、P2-F3 random 弱随机等 7 项审计发现未修复 | 重写时一并根治 | +| 一致性 | 项目已有 4 个成功的 C++ 重写案例(db2/dnfs2/captcha2/fsys) | 与 gateway/db2/dnfs2/captcha2 统一技术栈 | +| SMTP 会话 | `smtplib.SMTP_SSL` 封装良好但异常粒度粗 | 直接控制 OpenSSL 会话,更精细的超时/错误处理 | + +### 1.2 设计目标 + +1. **协议完全兼容**:pymsg 三条路由(`send_email` / `verify_email` / `health`)请求/响应格式与 Python 版字节级一致,gateway 零改动 +2. **审计清零**:修复安全审计报告(`smtp_SECURITY_AUDIT_20260801.md`)中全部未修复项 +3. **零新增重型依赖**:仅引入项目已安装的 OpenSSL(libssl-dev,各 C++ 模块均依赖) +4. **基础设施复用**:libmsg(RpcServer/AsyncQueue)、boost::json、Conf.hpp,所有已在项目内的组件 +5. **安全增强**:`std::random_device` 替代 `random`、常数时间比较、日志脱敏 + +--- + +## 2. 现状分析 + +### 2.1 源码结构 + +``` +smtp/ +├── smtp_v2.py # 317 行,服务主程序 +├── log_client.py # 245 行,异步日志 SDK(项目通用) +├── _set.sh # venv 创建 + pip install +├── _run.sh # venv 激活 + exec python3 smtp_v2.py +├── requirements.txt # ../pymsg + nuitka +└── .gitignore +``` + +### 2.2 功能清单 + +| 功能 | 代码位置 (smtp_v2.py) | 行数 | C++ 等效方案 | +|------|----------------------|------|-------------| +| .env 手动加载(4 种格式兼容) | L30–55 | 26 | Conf.hpp 的 ini 解析(与 gateway 一致) | +| 配置常量化 | L66–100 | 35 | `struct SmtpConfig` 编译时常量 + env 覆盖 | +| `SmtpServer.__init__` | L110–120 | 11 | `RpcServer` 构造 + `add_route` 注册 | +| `_handle_send_email` | L145–251 | 107 | SMTP_SSL 客户端 + 配额/冷却/熔断逻辑(~200 行 C++) | +| `_handle_verify_email` | L253–292 | 40 | 线程安全 Store + 常数时间比较(~70 行 C++) | +| `_handle_health` | L294–295 | 2 | 返回 `{"status":"ok","service":"smtp"}` | +| `main` / argparse | L302–316 | 15 | boost::program_options(与 gateway 一致) | +| log_client | 整个文件 | 245 | 基于 libmsg `AsyncQueue`,参照 captcha2 `LogC.hpp`(~60 行) | + +### 2.3 依赖替换矩阵 + +| Python 依赖 | C++ 替代 | 引入方式 | +|------------|---------|----------| +| `smtplib.SMTP_SSL` | 自写 SMTP_SSL 客户端(POSIX socket + OpenSSL BIO) | 系统已安装 `libssl-dev` | +| `email.mime.text.MIMEText` | 手写 MIME 拼装(~20 行字符串模板) | 无额外依赖 | +| `pymsg` | libmsg (`RpcServer` / `AsyncQueue`) | 系统已安装 (`libus_msg.so`) | +| `log_client.py` | 独立 `LogC.hpp`(基于 `AsyncQueue`) | 参照 captcha2 `LogC.hpp` | +| `random` / `secrets` | `` (`random_device` + `mt19937`) | C++11 标准库 | +| `threading.Lock` | `std::mutex` + `std::lock_guard` | C++11 标准库 | +| `json` | `boost::json` | 系统已安装 | +| `argparse` | `boost::program_options` | 系统已安装 | +| 手动 .env 解析 | Conf.hpp(ini 格式) / 环境变量 | 与 gateway 一致 | + +### 2.4 SMTP_SSL 会话流程分析 + +Python `smtplib.SMTP_SSL` 在端口 465 上的标准流程: + +``` +1. socket.create_connection((host, port), timeout=10) + → TCP 连接 +2. ssl.SSLContext.wrap_socket(sock) 或直接 SMTP_SSL 构造 + → TLS 握手(隐式 TLS,端口 465 专用) +3. server.ehlo() / helo() + → SMTP 握手,获取服务器能力 +4. server.login(user, password) + → AUTH LOGIN 认证 +5. server.sendmail(from_addr, [to_addr], msg.as_string()) + → MAIL FROM → RCPT TO → DATA → 发送 MIME 正文 +6. server.quit() + → QUIT 优雅关闭 +``` + +C++ 实现使用原始 POSIX socket + OpenSSL `BIO`(参照 libhttp HttpClient 的 TLS 栈),约 100 行。 + +--- + +## 3. 整体设计 + +### 3.1 模块结构 + +``` +smtp2/ +├── src/ +│ └── main.cpp # ~550 行,全部实现 +│ ├── SmtpConfig # 配置结构体 + 加载(~40 行) +│ ├── CodeStore # 线程安全验证码存储(~60 行) +│ ├── SendLimiter # 双窗口限流 + 全局熔断(~50 行) +│ ├── SmtpClient # SMTP_SSL 客户端(~100 行) +│ ├── generate_code() # 安全随机验证码(~10 行) +│ ├── build_mime() # MIME 邮件拼装(~20 行) +│ ├── route handlers # 三个路由处理(~150 行) +│ └── main() # 参数解析 + 配置 + 启动(~80 行) +├── inc/ +│ └── LogC.hpp # 简化版异步日志客户端(~60 行,参照 captcha2) +├── Makefile # 构建(~30 行) +├── _set.sh # make + install(~10 行) +├── _run.sh # 启动脚本(~5 行) +└── README.md +``` + +### 3.2 与现有 C++ 基础库的关系 + +``` +smtp2.bin + │ + ├── libmsg (RpcServer) ← pymsg 协议 C++ 实现,路由分发 + 响应包装 + │ └── libus_msg.so (系统已安装) + │ + ├── libmsg (AsyncQueue) ← 异步批量日志推送 + │ └── libus_msg.so + │ + ├── boost::json ← JSON 解析/序列化 + │ └── libboost_json (系统已安装) + │ + ├── boost::program_options ← 命令行参数解析 + │ └── libboost_program_options(系统已安装) + │ + ├── OpenSSL (libssl) ← SMTP_SSL TLS 会话 + │ └── libssl.so (系统已安装,各 C++ 模块均依赖) + │ + └── POSIX socket ← TCP 连接(标准系统调用) +``` + +### 3.3 配置体系 + +与 gateway/db2/dnfs2/captcha2 一致的两层加载:confs 拉取 `.ini`(若存在)→ 环境变量覆盖。 + +**smtp.ini(新增,托管在 confs/configs/)**: + +```ini +[server] +host = 127.0.0.1 +port = 8008 +workers = 4 + +[smtp] +server = smtp.163.com +port = 465 +user = your@163.com +password = your_smtp_password # 也支持环境变量 SMTP_PASSWORD 覆盖 +mail_from = # 可选,默认同 user +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 邮箱验证 +``` + +**环境变量覆盖(最高优先级,Docker 兼容)**: + +| 变量 | 对应配置项 | +|------|-----------| +| `SMTP_SERVER` | smtp.server | +| `SMTP_PORT` | smtp.port | +| `SMTP_USER` | smtp.user | +| `SMTP_PASSWORD` | smtp.password | +| `MAIL_FROM` | smtp.mail_from | +| `EMAIL_HOURLY_LIMIT` | smtp.email_hourly_limit | +| `EMAIL_DAILY_LIMIT` | smtp.email_daily_limit | +| `SMTP_HOURLY_LIMIT` | smtp.smtp_hourly_limit | + +向后兼容 `__PASSWORD` 环境变量(Python 版历史遗留,作为 `SMTP_PASSWORD` 的 fallback)。 + +--- + +## 4. 详细设计 + +### 4.1 CodeStore — 线程安全验证码存储 + +```cpp +class CodeStore { +public: + struct Entry { + std::string code; // 6 位数字验证码 + double expires_at; // unix timestamp(std::chrono → double) + int attempts = 0; // 连续校验失败次数 + }; + + // 写入验证码(覆盖已有);同时惰性清理过期条目 + void put(const std::string& email, const std::string& code, + double ttl_seconds); + + // 校验验证码:正确 → 删除并返回 true;错误 → attempts++, + // 达 max_attempts 时删除,invalidated 置 true 并返回 false + bool verify(const std::string& email, const std::string& code, + double ttl_seconds, int max_attempts, + bool& invalidated); + + // 删除指定邮箱的验证码(发送失败回滚用) + void remove(const std::string& email); + + // 惰性清理所有过期条目 + void expire(); + +private: + mutable std::mutex mtx_; + std::unordered_map store_; +}; +``` + +**与 Python 版的对应**:`code_store` dict + `STORE_LOCK`。C++ 版封装为独立类,校验通过后 `store_.erase(it)` 保证一次性消费;`verify()` 使用 `hmac_compare_digest` 风格常数时间比较(审计 P3-F4 修复)。 + +### 4.2 SendLimiter — 双窗口限流 + 全局熔断 + +```cpp +class SendLimiter { +public: + // 检查是否允许发送;允许时预占时间戳(返回 true + 记录 ts_id)。 + // ts_id 用于失败回滚(精确移除预占的时间戳)。 + bool try_consume(const std::string& email, + int hourly_limit, int daily_limit, + int global_hourly_limit, + uint64_t& ts_id); + + // 回滚预占:传入 try_consume 返回的 ts_id,精确移除对应的三条记录 + void rollback(const std::string& email, uint64_t ts_id); + +private: + // send_log[email] = {hour_deque, day_deque} + // global_send_log = deque> + // 惰性清理:每次 try_consume 时剔除滑出窗口的条目 + + mutable std::mutex mtx_; + struct EmailLog { + std::deque hour; + std::deque day; + }; + std::unordered_map send_log_; + std::deque> global_log_; + uint64_t next_ts_id_ = 1; +}; +``` + +**修复 P1-F1 "now 变量遮蔽"**:C++ 版使用单调递增 `ts_id`(`uint64_t`)标识每次预占,失败回滚时按 `ts_id` 精确移除,彻底消除 Python 版浮点时间戳 `==` 比较的不确定性——这是审计 P1-F1 的根本性修复。 + +**修复 P3-F6(subject/content 无长度上限)**:`_handle_send_email` 入口处校验 `subject` ≤ 256 字节、`content` ≤ 4096 字节,超限拒绝。 + +### 4.3 SmtpClient — SMTP_SSL 客户端 + +核心难点。Python `smtplib.SMTP_SSL` 封装了 TCP + TLS + SMTP 协议。C++ 手写实现约 100 行。 + +```cpp +class SmtpClient { +public: + struct Config { + std::string server; + int port = 465; + std::string user; + std::string password; + std::string mail_from; // 空则用 user + double timeout = 20.0; // 完整 SMTP 会话超时 + }; + + explicit SmtpClient(const Config& cfg); + + // 发送一封邮件;失败抛出 std::runtime_error(含可读错误消息)。 + // 调用方可选择脱敏后返回或记录日志。 + void send(const std::string& to, + const std::string& subject, + const std::string& body_mime); + +private: + // 内部: + // 1. socket() → connect() 创建 TCP 连接 + // 2. SSL_CTX_new(TLS_client_method()) → SSL_new → SSL_set_fd → SSL_connect + // 3. SMTP 协议交互: + // EHLO → AUTH LOGIN → MAIL FROM → RCPT TO → DATA → QUIT + // 4. 每步读取响应码(2xx/3xx 为成功,4xx/5xx 抛异常) + + Config cfg_; +}; +``` + +**SMTP 协议交互伪代码**: + +```cpp +void SmtpClient::send(const std::string& to, + const std::string& subject, + const std::string& body_mime) { + // 1. TCP connect + int sock = socket(AF_INET, SOCK_STREAM, 0); + // ... resolve host, connect, set SO_RCVTIMEO/SO_SNDTIMEO ... + + // 2. TLS handshake + SSL_CTX* ctx = SSL_CTX_new(TLS_client_method()); + SSL* ssl = SSL_new(ctx); + SSL_set_fd(ssl, sock); + SSL_connect(ssl); + + // 3. SMTP dialogue + read_response(ssl, 220); // greeting + send_cmd(ssl, "EHLO localhost"); read_response(ssl, 250); + send_cmd(ssl, "AUTH LOGIN"); + read_response(ssl, 334); // Username: + send_cmd(ssl, base64_encode(cfg_.user)); + read_response(ssl, 334); // Password: + send_cmd(ssl, base64_encode(cfg_.password)); + read_response(ssl, 235); // Authentication successful + + send_cmd(ssl, "MAIL FROM:<" + from + ">"); read_response(ssl, 250); + send_cmd(ssl, "RCPT TO:<" + to + ">"); read_response(ssl, 250); + send_cmd(ssl, "DATA"); read_response(ssl, 354); + send_data(ssl, body_mime + "\r\n.\r\n"); read_response(ssl, 250); + + send_cmd(ssl, "QUIT"); read_response(ssl, 221); + + // 4. cleanup + SSL_shutdown(ssl); SSL_free(ssl); SSL_CTX_free(ctx); + close(sock); +} +``` + +**超时策略**:`SO_RCVTIMEO` + `SO_SNDTIMEO` 设为 20s(与 gateway `_TIMEOUT_SMTP_SEND` 一致)。OpenSSL 的 `SSL_connect` / `SSL_read` / `SSL_write` 遇到 socket 超时会返回错误,被外围 try-catch 捕获并转换为 `std::runtime_error`。 + +**Base64 编码**:复用 libcrypt `Base64::encode()`(`libus_crypt.so`),免去手写。 + +**MIME 拼装**(`build_mime()`,~20 行): +```cpp +std::string build_mime(const std::string& from, + const std::string& to, + const std::string& subject, + const std::string& body_plain) { + std::ostringstream os; + os << "From: " << from << "\r\n" + << "To: " << to << "\r\n" + << "Subject: =?UTF-8?B?" << base64_encode(subject) << "?=\r\n" + << "MIME-Version: 1.0\r\n" + << "Content-Type: text/plain; charset=UTF-8\r\n" + << "Content-Transfer-Encoding: base64\r\n" + << "\r\n" + << wrap_base64_lines(body_plain); // 每 76 字符换行(RFC 2045) + return os.str(); +} +``` + +### 4.4 路由处理 + +```cpp +// send_email 路由 +json::value handle_send_email(const json::object& req) { + // 1. 取出 email → 空或缺 @ → 返回 invalid email + // 2. SMTP 配置完整性校验(server/user/password 均非空) + // 3. 发送冷却检查(60s)→ 冷却中返回 retry_after + // 4. 双窗口邮箱限流 + 全局熔断预占 → 超限返回对应错误 + // 5. 生成验证码 → 写入 CodeStore + // 6. 调用 SmtpClient.send() + // - 成功:刷新冷却时间戳,惰性清理过期冷却/验证码条目 + // - 失败:回滚 CodeStore、冷却预占、限流预占;返回失败错误 + // 7. 返回 {sent: true, ttl: CODE_TTL} + + // ★ 审计修复清单(在重写中一并解决): + // P1-F1:ts_id 精确回滚(§4.2 SendLimiter) + // P2-F2:日志不打印验证码明文,仅记邮箱 + "验证码发送成功" + // P2-F3:generate_code() 使用 std::random_device + // P3-F5:邮箱格式用与 gateway 一致的严格正则 + // P3-F6:subject ≤ 256B, content ≤ 4096B + // P3-F7:失败响应返回通用文案,SMTP 细节仅入日志 +} + +// verify_email 路由 +json::value handle_verify_email(const json::object& req) { + // 1. 取出 email + code(兼容旧字段 input_captcha)→ 空值返回 error + // 2. 调 CodeStore.verify() 常数时间比较 → 返回 valid/invalidated + // 3. 日志仅记邮箱 + "验证通过" / "验证失败" + // ★ 审计修复:P3-F4 常数时间比较(hmac_compare_digest 风格) +} + +// health 路由 +json::value handle_health(const json::object&) { + return {{"status", "ok"}, {"service", "smtp"}}; +} +``` + +### 4.5 generate_code() — 安全随机验证码 + +```cpp +std::string generate_code(int len = 6) { + // thread_local 保证 RpcServer workers>0 时多线程安全 + thread_local std::mt19937 rng(std::random_device{}()); + std::uniform_int_distribution dist(0, 9); + + std::string code; + code.reserve(len); + for (int i = 0; i < len; ++i) + code.push_back('0' + dist(rng)); + return code; +} +``` + +**审计修复(P2-F3)**:Python 版使用 `random.randint`(MT19937 但种子来自系统时间等确定性源),C++ 版 `std::random_device` 作为种子源,提供真正的非确定性熵(Linux 上映射到 `/dev/urandom`)。 + +### 4.6 LogC.hpp — 异步日志客户端 + +与 captcha2 的 `LogC.hpp` 完全一致(参照 `captcha2/inc/LogC.hpp`),基于 libmsg `AsyncQueue`: +- 异步批量推送(攒满 50 条或 2 秒 flush) +- fail-safe(db 不可用静默丢弃) +- `push()` / `info()` / `warning()` / `error()` API +- 环境变量 `DB_HOST` / `DB_PORT` 覆盖 + +--- + +## 5. 实现计划 + +### 5.1 分阶段交付 + +| 阶段 | 里程碑 | 关键产出 | 预估工时 | +|------|--------|----------|----------| +| **P0 — 基础设施** | 目录骨架 + Makefile + LogC.hpp | `smtp2/` 结构搭建,能编译出空 `main()` 链接 libmsg + OpenSSL | 0.5h | +| **P1 — SmtpClient** | SMTP_SSL 客户端全链路 | POSIX socket → OpenSSL TLS → SMTP EHLO/AUTH/MAIL/DATA/QUIT 完整会话,独立测试程序可发一封真实邮件 | 4h | +| **P2 — CodeStore + SendLimiter** | 线程安全存储 + 限流熔断 | `CodeStore` 生成/校验/过期/作废 + `SendLimiter` 双窗口/全局熔断/ts_id 精确回滚 | 2h | +| **P3 — 路由集成** | `RpcServer` 三路由 + 命令行参数 | 能与 gateway 联调,pymsg 协议互通 | 1.5h | +| **P4 — 配置加载** | confs smtp.ini 拉取 + env 覆盖 + 向后兼容 | 与 Python 版 `.env` 格式兼容,`__PASSWORD` 别名支持 | 1h | +| **P5 — 审计修复验证** | 逐条对照审计报告确认修复 | P1-F1 / P2-F2 / P2-F3 / P3-F4~F7 全部标绿 | 1h | +| **P6 — 测试** | 黑盒测试 + SMTP mock 测试 | `tests/test_smtp2.py` | 2h | +| **P7 — 文档 + 部署** | README + _set.sh/_run.sh + init.txt 更新 | 完整文档 + 一键安装 + core 自启动 | 0.5h | + +**总计预估:~12.5 小时** + +### 5.2 P1 SmtpClient 是高风险路径 + +P1 阶段涉及 OpenSSL TLS 握手与 SMTP 协议状态机,是重写中**唯一的外部网络 I/O 路径**。策略: + +1. **独立测试程序**:先写一个 `smtp2/test_send.cpp`,硬编码 SMTP 凭据,独立验证"连接 → TLS → 认证 → 发送 → 断开"全流程 +2. **逐响应码验证**:SMTP 服务器返回 220/250/334/235/354/221 的协议序列化,每步 assert +3. **异常路径测试**:错误密码 → 认证失败、错误邮箱 → RCPT TO 拒绝、网络断开 → socket 超时 +4. **多服务商兼容**:至少验证 163.com(465 端口隐式 TLS)一种主流配置 + +### 5.3 构建系统 + +```makefile +# smtp2/Makefile +CXX = g++ +CXXFLAGS = -std=c++20 -Wall -O2 +INCS = -I./inc +LIBS = -lus_msg -lboost_json -lboost_program_options -lus_crypt -lssl -lcrypto -lpthread + +TARGET = smtp2.bin +SRC = src/main.cpp + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) $(INCS) $^ $(LIBS) -o $@ + +install: $(TARGET) + cp $(TARGET) /usr/local/bin/ + +clean: + rm -f $(TARGET) +``` + +> **说明**:`-lssl -lcrypto` 提供 OpenSSL 的 TLS 和 base64;`-lus_crypt` 备选 base64(libcrypt 已封装),实际按需二选一。无新增系统包。 + +--- + +## 6. 测试计划 + +### 6.1 黑盒测试(`tests/test_smtp2.py`) + +复用现有测试框架(`tests/blackbox/helpers.py` 的 `PymsgClient`),覆盖: + +| 测试用例 | 说明 | +|----------|------| +| `test_send_email_valid` | 正常发送 → `{"sent": true, "ttl": 600}` | +| `test_send_invalid_email` | 不含 `@` → `{"sent": false, "error": "invalid email"}` | +| `test_send_missing_config` | SMTP 未配 → `{"sent": false, "error": "SMTP 服务未配置"}` | +| `test_send_cooldown` | 60s 内连发两次 → 第二次返回 `retry_after` 且不覆盖验证码 | +| `test_send_email_hourly_limit` | 超过 3 次/小时 → 返回配额耗尽错误 | +| `test_send_email_daily_limit` | 超过 10 次/天 → 返回每日上限错误 | +| `test_send_global_fuse` | 全局 100 次/小时触发 → 返回全局熔断 | +| `test_send_failure_rollback` | Mock SMTP 失败 → 验证码/冷却/配额全部回滚(**P1-F1 回归测试**) | +| `test_verify_correct` | 正确验证码 → `{"valid": true}`(一次性消费) | +| `test_verify_wrong` | 错误验证码 → `{"valid": false, "error": "invalid or expired code"}` | +| `test_verify_replay` | 校验通过后再次校验 → 第二次也应返回 `invalid or expired code` | +| `test_verify_invalidated` | 连续 5 次错误 → `{"valid": false, "invalidated": true}` | +| `test_verify_expired` | 超时后校验 → `{"valid": false, "error": "invalid or expired code"}` | +| `test_verify_missing_fields` | 缺少 email 或 code → `{"valid": false, "error": "email and code required"}` | +| `test_verify_compat_input_captcha` | 使用旧字段 `input_captcha` 传递验证码 → 正常校验 | +| `test_health` | → `{"status": "ok", "service": "smtp"}` | + +### 6.2 SMTP Mock 测试 + +P1-F1 回归测试需要 SMTP 失败的场景,不能依赖真实 SMTP 服务。方案: + +1. **独立 mock 程序**(`smtp2/test_mock_smtp.py`):在本地随机端口启动一个极简 SMTP 服务器(仿 220/250/334/235/354/221 协议但 `RCPT TO` 后返回 550 拒绝),smtp2.bin 连接该 mock 端口 +2. **验证链路**:发送请求 → smtp2 连接 mock → mock 返回 550 → smtp2 触发失败回滚 → 验证 store/limiter 中无残留 + +### 6.3 gateway 联调 + +1. 启动 smtp2.bin 替代 Python smtp(监听 `127.0.0.1:8008`) +2. `POST /register/send_code` → 正常返回 `{"success": true}` +3. 邮箱收到验证码,`POST /register` 校验通过 +4. 重置密码流程(`/reset/send_code` → `/reset/password`)完整走通 + +--- + +## 7. 迁移计划 + +### 7.1 渐进切换 + +``` +阶段 1: smtp2.bin 开发完成,通过全部测试(包括 SMTP mock 测试) + ↓ +阶段 2: 在测试环境与 Python smtp 并行运行(不同端口),全量测试 + ↓ +阶段 3: 修改 core/init.txt,将 smtp/_run.sh 替换为 smtp2/_run.sh + ↓ +阶段 4: 生产切换,观察 1 周无异常后删除 Python smtp 目录 +``` + +### 7.2 gateway 零改动保证 + +- pymsg 协议不变(JSON RPC 约定不变) +- 监听地址不变(`127.0.0.1:8008`) +- 三条路由 `send_email` / `verify_email` / `health` 请求/响应格式字节级兼容 +- gateway 的 `handleSendEmailCode()` / `verify_email_code_remote()` / `handleResetSendCode()` 通过 pymsg `MsgClient` 调用,与 Python 版完全兼容 + +### 7.3 回滚方案 + +- `init.txt` 中恢复 `bash /opt/micro_servers/smtp/_run.sh` +- Python venv 保持不变(不删除),可立即回滚 + +### 7.4 配置文件迁移 + +``` +现有: smtp/.env (gitignore 保护) +新增: confs/configs/smtp.ini (纳入 confs 统一管理) +兼容: 环境变量 SMTP_* 优先级最高,与 .env 行为一致 +平滑: smtp2 启动时先读 confs smtp.ini → 环境变量覆盖 → 完整性校验 +``` + +--- + +## 8. 安全考量 + +### 8.1 审计遗留修复清单 + +以下 7 项审计发现在 C++ 重写中**一并根治**: + +| 编号 | 等级 | 问题 | C++ 版修复 | +|------|------|------|-----------| +| **P1-F1** | P1 | `now` 变量遮蔽 → SMTP 失败时限流/熔断回滚失效 | `SendLimiter` 使用单调 `ts_id` 精确回滚(§4.2) | +| **P2-F2** | P2 | 验证码明文写入集中日志 Logs 表 | 日志仅记邮箱 + 事件名("验证码发送成功"),不做任何编码记录 | +| **P2-F3** | P2 | `random.randint` 非密码学安全随机 | `std::random_device` 种子 + `std::mt19937`(§4.5) | +| **P3-F4** | P3 | 验证码比较非常数时间 | `std::string_view` 逐字符异或比较(短路无分支)或 `CRYPTO_memcmp`(§4.1) | +| **P3-F5** | P3 | 邮箱格式仅检查 `@` | 与 gateway 一致的正则:`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` | +| **P3-F6** | P3 | `subject`/`content` 无长度上限 | 上限 256 / 4096 字节,超限拒绝 | +| **P3-F7** | P3 | 失败响应直传 SMTP 错误原文 | smtp2 自身返回通用文案,SMTP 细节仅入本地日志 | + +### 8.2 已有安全机制保持 + +| 机制 | Python 版 | C++ 版 | +|------|----------|--------| +| 发送冷却 60s | `send_history` + `STORE_LOCK` | `std::unordered_map` + `std::mutex` | +| 冷却失败回滚 | `send_history.pop(email, None)` | `cooling_store_.erase(email)` —— 直接 erase,无 P1-F1 风险 | +| 验证码一次性消费 | `del code_store[email]` | `store_.erase(it)` | +| 连续失败 5 次作废 | `entry['attempts'] >= 5 → del` | `entry.attempts >= max → erase + invalidated=true` | +| 10 分钟过期 | 惰性清理(`verify_email` + `send_email` 时) | 同 | +| 邮箱级双窗口限流 | `send_log` + `global_send_log` + 惰性清理 | `SendLimiter`(ts_id 精确回滚) | +| 全局熔断 | `SMTP_HOURLY_LIMIT`(默认 100) | 同 | +| 仅监听 127.0.0.1 | `RpcServer("127.0.0.1", ...)` | 同 | +| 配置缺失不报 value | `missing = [k for k, v ... if not v]` | 同 | +| fail-closed | 异常时 server 不存在 → 校验失败 | `catch (std::exception&)` → 返回 false | + +### 8.3 RpcServer 安全特性(继承自 libmsg) + +- JSON 解析嵌套深度限制 32 层 +- 错误信息脱敏(不泄露内部路径/类型名) +- `recv_timeout` 防 Slowloris(默认 10s) +- Handler 异常不导致服务崩溃 + +### 8.4 新增安全考量:SMTP 凭据管理 + +Python 版 SMTP 凭据从 `.env` 文件加载(gitignore 保护)。C++ 版支持两种凭据来源: + +1. **confs smtp.ini**(推荐):纳入 confs 统一管理,凭据与其他配置一起分发 +2. **环境变量**(兼容):`SMTP_PASSWORD` 优先级最高,兼容 Docker secret + +`password` 字段仅出现在 `[smtp]` 段,不出现在日志或错误响应中。配置加载失败时报告缺失的 key 名,不打印值。 + +--- + +## 9. 新增/修改文件清单 + +### 新增 + +``` +smtp2/ +├── src/ +│ └── main.cpp # ~550 行,全部实现 +├── inc/ +│ └── LogC.hpp # ~60 行(与 captcha2 共用同一份) +├── Makefile # ~30 行 +├── _set.sh # make + install(~10 行) +├── _run.sh # 启动脚本(~5 行) +├── test_send.cpp # SmtpClient 独立测试(~50 行) +└── README.md # 模块文档 +``` + +### 需修改 + +| 文件 | 修改内容 | +|------|----------| +| `confs/configs/smtp.ini` | **新增**,smtp 统一配置文件 | +| `core/init.txt` | `smtp/_run.sh` → `smtp2/_run.sh` | +| `docs/INDEX.md` | 添加本文件的索引条目 | + +### 零改动 + +gateway / waf / db / 其他服务均零改动。接口字节级兼容。 + +--- + +## 10. 依赖清单 + +| 依赖 | 用途 | 是否新增 | 安装方式 | +|------|------|----------|----------| +| `libus_msg.so` (libmsg) | RPC 服务端 + 异步日志 | 否 | 已通过 `_inst.sh` 安装 | +| `libboost_json` | JSON 解析 | 否 | 已通过 `_setup.deb.sh` 安装 | +| `libboost_program_options` | 命令行参数 | 否 | 已通过 `_setup.deb.sh` 安装 | +| `libus_crypt.so` (libcrypt) | Base64 编码 | 否 | 已通过 `_inst.sh` 安装 | +| `libssl.so` (OpenSSL) | SMTP_SSL TLS 会话 | 否 | 已通过 `_setup.deb.sh` 安装(`libssl-dev`) | +| `libcrypto.so` (OpenSSL) | 常数时间比较 `CRYPTO_memcmp` | 否 | 随 libssl 安装 | + +**零新增系统依赖。** OpenSSL 已是项目所有 C++ 模块的共有依赖(libhttp / libcrypt 均链接 `-lssl -lcrypto`),无须额外 `apt install`。 + +--- + +## 11. 与 Python 版的差异汇总 + +| 项目 | Python 版 | C++ 版 | 影响 | +|------|----------|--------|------| +| 运行时 | Python 3 + venv | 单二进制 `smtp2.bin` | 消除 Python 依赖 | +| 随机数 | `random.randint(0,9)` | `mt19937(random_device())` | 安全增强(P2-F3) | +| 日志内容 | `email: 验证码发送成功(123456)` | `email: 验证码发送成功` | 审计 P2-F2 修复 | +| 限流回滚 | `now` 变量遮蔽 → 预占回滚失效 | `ts_id` 精确匹配回滚 | P1-F1 根治 | +| 验证码比较 | 普通 `==` | `CRYPTO_memcmp` 常数时间 | P3-F4 修复 | +| 邮箱校验 | 仅 `"@" in email` | 与 gateway 一致的严格正则 | P3-F5 修复 | +| 输入长度 | subject/content 无上限 | 256 / 4096 字节硬上限 | P3-F6 修复 | +| 错误响应 | 含 SMTP 服务器原始错误 | 通用文案脱敏 | P3-F7 修复 | +| 进程模型 | Python GIL(仅 `workers=4` 时 I/O 并发受限) | 原生 C++ 多线程 | 真实并发能力 | +| 配置格式 | 手动解析 `.env`(4 种格式) | confs ini + 环境变量覆盖 | 标准化,与 gateway 一致 | +| 凭据管理 | `.env` 文件 | confs smtp.ini 或环境变量 | 纳入 confs 统一管理 | + +--- + +## 12. 风险与应对 + +| 风险 | 概率 | 影响 | 应对 | +|------|------|------|------| +| OpenSSL SMTP_SSL 实现有协议细节疏漏(如 AUTH LOGIN 编码、响应码多行处理) | 中 | 高 | P1 独立测试程序 + 至少 163.com 实战验证;参考 `smtplib` 源码的协议状态机 | +| SMTP 响应码 250 含多行文本(如 `250-SIZE 104857600\r\n250 AUTH LOGIN PLAIN`),单行解析丢失信息 | 低 | 中 | 按 RFC 5321 §4.2 实现多行响应解析(`xxx-` 开始,`xxx ` 结束) | +| 不同 SMTP 服务商(163/QQ)TLS 行为差异 | 低 | 中 | P1 阶段多服务商验证;端口 465(隐式 TLS)仅需标准 TLS 握手 | +| gateway 联调时 pymsg 协议兼容性问题 | 极低 | 高 | libmsg 已在 db2/dnfs2/captcha2/fsys 中经过充分验证 | +| `ts_id` 溢出(`uint64_t`,每秒 100 万次可用 58 万年) | 无 | 无 | 理论上不可能 | + +### 12.1 SMTP 多行响应处理 + +Python `smtplib` 内部处理了 RFC 5321 的多行响应格式(`250-SIZE\r\n250-AUTH\r\n250 OK`)。C++ 实现需同等处理: + +```cpp +// SMTP 响应 ⇒ 提取最终响应码 +// RFC 5321: "250-SIZE\r\n250-AUTH\r\n250 OK" ⇒ 码=250 +std::pair parse_smtp_response(const std::string& raw) { + // 最后一行格式: "250 OK"(空格分隔,非 '-') + // 中间行格式: "250-SIZE"('-' 表示后续还有续行) + ... +} +``` + +在 P1 阶段独立测试程序中加入多行响应的单元测试。 + +--- + +## A. 附录:与 captcha2 重写的对比 + +| 维度 | captcha2 | smtp2 | +|------|----------|-------| +| Python 源码行数 | 299 行 | 317 行 | +| 核心难点 | FreeType 字体渲染 + 像素旋转(CPU 密集,~180 行) | SMTP_SSL 客户端 + 协议状态机(网络 I/O,~100 行) | +| 新增外部依赖 | FreeType(libfreetype-dev) | **零**(全部复用现有依赖) | +| 审计修复项 | P0-1 大小写 / P2-2 tolower UB | **7 项全部未修复**(P1-F1/P2-F2/P2-F3/P3-F4~F7) | +| 预估工时 | 16h | 12.5h | +| 安全随机 | `mt19937(random_device)` | 同 | +| 日志客户端 | LogC.hpp(AsyncQueue) | 同(共用 captcha2 版本) | +| 配置体系 | confs ini + env | confs ini + env(新增向后兼容 `__PASSWORD`) | + +smtp2 相比 captcha2 的显著优势:**零新增系统依赖**(OpenSSL 已就位),且解决了 Python 版积压的全部 7 项审计发现,属于"安全债务集中清偿"型重写。 + +--- + +## B. 附录:Python smtplib.SMTP_SSL 源码参考路径 + +- Python 标准库: `Lib/smtplib.py`(`SMTP_SSL` 类,约 L1010-1060) +- 关键方法: `__init__`(socket + SSL wrap)、`login`(AUTH LOGIN 编码)、`sendmail`(MAIL/RCPT/DATA 序列)、`quit`、`docmd`(发送命令并读响应) +- 响应解析: `getreply()` 处理多行 `123-` 续行格式 + +C++ 实现不需要完全复刻 `smtplib` 的全部功能(如 STARTTLS、8BITMIME),仅需覆盖 smtp 模块实际使用的子集:隐式 TLS(465 端口)、AUTH LOGIN、MAIL FROM/RCPT TO/DATA/QUIT。 + +--- + +## C. 附录:SMTP 响应码速查 + +| 码 | 含义 | 预期阶段 | +|----|------|---------| +| 220 | Service ready | 连接 greeting | +| 250 | OK | EHLO / MAIL FROM / RCPT TO / DATA 结束 | +| 334 | Send auth data | AUTH LOGIN 的用户名/密码提示 | +| 235 | Authentication successful | AUTH 完成 | +| 354 | Start mail input | DATA 后的就绪信号 | +| 221 | Service closing | QUIT | +| 421 | Service unavailable | 临时故障(应重试) | +| 450 | Mailbox unavailable | 邮箱不可用 | +| 535 | Authentication failed | 认证失败 | +| 550 | Mailbox not found | 邮箱不存在 | + +所有非 2xx/3xx 响应均为错误,C++ 版逐码抛出含响应行的 `std::runtime_error`,由上层 handler 捕获后脱敏返回。 diff --git a/docs/security/smtp3_SECURITY_AUDIT_20260809.md b/docs/security/smtp3_SECURITY_AUDIT_20260809.md new file mode 100644 index 0000000..4f1d7c6 --- /dev/null +++ b/docs/security/smtp3_SECURITY_AUDIT_20260809.md @@ -0,0 +1,269 @@ +# smtp3 模块安全审计报告 + +**审计日期**: 2026-08-09 +**审计范围**: `smtp3/src/main.cpp`、`smtp3/inc/LogC.hpp`、`smtp3/Makefile`、`smtp3/_run.sh`、`smtp3/_set.sh`、`confs/configs/smtp.ini` +**代码版本**: `main` @ `6bd0fca` +**审计方式**: 静态代码审查 + 可疑逻辑最小化复现验证(本机编译执行)+ 与 Python 版 `smtp_v2.py`、gateway 调用方、libmsg 基础库交叉比对 +**参照文档**: [smtp_SECURITY_AUDIT_20260801.md](smtp_SECURITY_AUDIT_20260801.md)(Python 版审计,含 0809 复审)、[smtp_cpp_rewrite_dev_doc.md](../reports/smtp_cpp_rewrite_dev_doc.md)(重写开发方案) + +--- + +## 一、结论速览 + +| 等级 | 数量 | 已修复 | 说明 | +|------|------|--------|------| +| P0 | 2 | 2 ✅ | SMTP 响应解析逻辑颠倒(发送 100% 失败)、base64 符号扩展(UTF-8 内容乱码) | +| P1 | 2 | 1 ✅ | P1-2 SIGPIPE 已修;P1-1 TLS 无证书校验/无 SNI 待修 | +| P2 | 1 | 1 ✅ | SMTP 响应读取无大小上限(随 P0-1 一并修复) | +| P3 | 5 | 5 ✅ | 全部修复:P3-2 随 P0-1;P3-1/3/4/5 于 2026-08-09 修复并经运行时验证 | + +**总体结论**: smtp3 对 Python 版 7 项审计遗留(P1-F1 ~ P3-F7)的修复**全部落实且实现正确**(见 §七),路由协议与响应格式与 gateway 调用方**完全兼容**(见 §八)。但 `SmtpClient`(重写中新手写的 SMTP_SSL 客户端)存在 **2 个 P0 级功能阻断缺陷**——本审计已在本机编译复现实证:**审计时点的二进制不具备发出任何一封邮件的能力**。此前的重写验证只覆盖了编译、启动、confs 拉取与 `--help`,未覆盖真实 SMTP 会话(smtp.ini 凭据为空),导致 P0 未被发现。 + +**修复进展(2026-08-09 当日)**: P0-1 / P0-2 已修复并重建二进制,顺带关闭 P2-1 / P3-2;P1-2(SIGPIPE)已修复并经运行时信号投递验证;P3-1 / P3-3 / P3-4 / P3-5 已修复并经运行时验证(日志脱敏实测、LogC 长连接写入 db 实测);修复代码经 14 项逻辑验证用例全部通过(见 §十一)。**剩余:P0/P1-2/P2/P3 已全部关闭;上线阻断条件收敛为:P1-1(TLS 校验)修复 + 真实 SMTP 账号端到端发信验证**,在此之前仍禁止用 smtp3 替换 Python 版 smtp。 + +**部署现状**(截至本审计):`core/init.txt` 仍拉起 Python 版 `smtp/_run.sh`;`_inst.sh` 仅含 `smtp` 未含 `smtp3`;`confs/configs/smtp.ini` 的 `server/user/password` 为空。smtp3 尚未上线,上述 P0 未对生产造成实际影响。 + +--- + +## 二、🔴 P0 — 致命问题(发布阻断) + +### P0-1: `read_response` 行解析逻辑颠倒,所有 SMTP 会话必然阻塞超时,send_email 100% 失败 ✅ 已修复 + +- **位置**: `src/main.cpp` L567-599(`SmtpClient::read_response`) +- **问题**: 判定"已收到最终响应行"的逻辑是取 `full.rfind("\r\n")` **之后**的内容作为最后一行: + + ```cpp + auto last_crlf = full.rfind("\r\n"); + if (last_crlf != std::string::npos) { + std::string last_line = full.substr(last_crlf + 2); // ← 取尾部 CRLF 之后 + if (last_line.size() >= 4 && last_line[3] == ' ') { ... return full; } + } + // 否则继续 SSL_read + ``` + + SMTP 响应每行**以 CRLF 结尾**(RFC 5321 §4.2),完整响应 `"220 OK\r\n"` 的 `rfind("\r\n")` 命中的是**尾部** CRLF,`last_line` 为空串 → 判定"未读完" → 继续 `SSL_read` → 服务器不会再发数据 → 阻塞至 20 秒 socket 超时 → 抛 `SSL_read 失败`。逻辑完全反了:只有**残缺**的 TCP 分包(最终行尚未收到尾部 CRLF)才会被判定为"读完"。 +- **实证**(提取原逻辑最小化复现,2026-08-09 本机执行): + + | 输入(模拟一次 SSL_read 收到的数据) | 实际判定 | 正确判定 | + |---|---|---| + | `"220 OK\r\n"`(标准单行完整响应) | ❌ 继续读 → 超时 | 应返回 | + | `"250-A\r\n250 OK\r\n"`(多行完整响应) | ❌ 继续读 → 超时 | 应返回 | + | `"250-A\r\n250 OK"`(无尾 CRLF 的残缺包) | ⚠️ 提前返回 | 应继续读 | + +- **影响**: + 1. **功能阻断**: 第一步 `read_response(ssl, 220)`(greeting)即挂死,任何配置下 send_email 均在 ~20s 后以"验证码发送失败"告终 → 注册 / 重置密码全流程不可用。 + 2. **DoS 放大**: 每个 send_email 请求占住一个 worker 线程 ~20s;`workers = 4` 时 **4 个并发请求即可占满全部 worker**,期间 `health` / `verify_email` 全部无响应。且发送失败会回滚冷却与限流预占("失败不计入配额"语义),客户端可无限重试,阻断可持续维持。 +- **修复建议**: 改为按**完整行**解析——查找每个以 CRLF 结尾的行,检查**最后一个完整行**是否匹配 `^\d{3} `(第 4 字符为空格即最终行,`-` 为续行);缓冲区尾部未闭合的数据视为未完成继续读。修复后必须用真实 SMTP 账号完成一次端到端发信验证。 +- **修复记录(2026-08-09)**: 已按建议重写为完整行解析(`main.cpp` `read_response`),响应码改逐字符数字校验(顺带关闭 P3-2),累计缓冲加 64KB 上限(顺带关闭 P2-1)。验证:单行/多行完整响应正确返回,残缺包/续行/空缓冲正确续读,码不匹配走异常路径,14 项用例全部通过;`-Wall -Wextra` 零警告重建 `smtp3.bin`。端到端真实发信验证待 smtp.ini 凭据就绪后执行。 + +### P0-2: `base64_encode` 对 `char` 符号扩展,所有含非 ASCII 字节的输入编码损坏(默认中文文案即中招) ✅ 已修复 + +- **位置**: `src/main.cpp` L80-94(`base64_encode`),调用方 L473-475(AUTH LOGIN)、L645-646(MIME 主题/正文) +- **问题**: `uint32_t n = (uint32_t(data[i]) << 16)` —— `data[i]` 是 `char`(x86-64 Linux 默认有符号),字节 ≥ `0x80` 时先符号扩展为 `0xFFFFFFxx` 再移位,高位污染同组其余字节: + + ```cpp + uint32_t n = (uint32_t(data[i]) << 16); // 0xE9 → 0xFFFFFFE9 → 0xFFE90000 + if (i + 1 < data.size()) n |= (uint32_t(data[i + 1]) << 8); // OR 进一步互相污染 + ``` + +- **实证**(原函数最小化复现,2026-08-09 本机执行): + + | 输入 | 实际输出 | 正确输出 | + |---|---|---| + | `"Hello"`(纯 ASCII) | `SGVsbG8=` ✅ | `SGVsbG8=` | + | `"DNFS 邮箱验证"`(生产默认主题) | `RE5G///p///n///p///o/4E=` ❌ | `RE5GUyDpgq7nrrHpqozor4E=` | + +- **影响**: + 1. **默认主题与正文均为中文**(`"DNFS 邮箱验证"` / `"DNFS: 您的邮箱验证码为 {code},10 分钟内有效。"`),base64 损坏后邮件客户端解码出乱码/解码失败——**即使 P0-1 修复,用户也拿不到可读的验证码**; + 2. **AUTH LOGIN 凭据受损**: 若 SMTP 账号或密码含非 ASCII 字符(部分服务商授权码允许中文),`base64_encode(user/password)` 输出错误,认证必然失败且被脱敏文案掩盖; + 3. 纯 ASCII 内容(如英文 subject)不受影响——这是该缺陷隐蔽的原因,但本项目默认配置恰好全部命中。 +- **修复建议**: 移位前统一转无符号:`uint32_t(data[i] & 0xFF)`(或 `unsigned char` 强转),三处移位全部适用。修复后以 `"DNFS 邮箱验证"` 等 UTF-8 样例做编码往返单测(与 Python `base64.b64encode` 输出逐字节比对)。 +- **修复记录(2026-08-09)**: 三处移位已改为 `uint32_t(uint8_t(data[i]))`。验证:`"DNFS 邮箱验证"`、中文正文(含验证码替换后形态)、全高位字节 `\xE9\xE9\xE9`、填充边界共 5 项用例与 Python `base64.b64encode` 基准逐字节一致,全部通过。 + +--- + +## 三、🟠 P1 — 高危问题 + +### P1-1: TLS 客户端无证书校验、无主机名校验、无 SNI、无最低版本钳制 + +- **位置**: `src/main.cpp` L457-463(`SmtpClient::send` TLS 握手段) +- **问题**: 握手只做 `SSL_CTX_new` → `SSL_new` → `SSL_set_fd` → `SSL_connect`,全程: + - 未调用 `SSL_CTX_set_default_verify_paths` + `SSL_CTX_set_verify(VERIFY_PEER)` → **不校验服务器证书链**; + - 未调用 `SSL_set1_host` / `X509_VERIFY_PARAM_set1_host` → **不校验证书主机名**; + - 未调用 `SSL_set_tlsext_host_name` → **不发送 SNI**(多租户 SMTP 端点可能握手失败或拿到错误证书); + - 未钳制最低 TLS 版本(`SSL_CTX_set_min_proto_version`)。 +- **影响**: SMTP 出站链路存在主动中间人时,攻击者可完整截获 `AUTH LOGIN` 凭据(base64 明文语义)与验证码正文,且客户端毫无感知。Python 版 `smtplib.SMTP_SSL` 在 Python 3.4+ 同样默认不校验证书(同源缺陷,见 smtp 版审计),故本项**不属于回归**,但重写是消除该缺陷的正确时机。 +- **修复建议**: 加载系统 CA(`SSL_CTX_set_default_verify_paths`),`SSL_set1_host(ssl, server)`,`SSL_set_tlsext_host_name`,`SSL_CTX_set_min_proto_version(TLS1_2_VERSION)`。如需兼容自签证书可加配置项 `verify_cert = on/off`(默认 on)。 + +### P1-2: 未忽略 SIGPIPE,SMTP 会话中途被服务器断开时整个 smtp3 进程被内核终止 ✅ 已修复 + +- **位置**: `src/main.cpp`(全文无信号处理);对比 `gateway/src/main.cpp` L773-804(显式 `sigaction(SIGPIPE, SIG_IGN)` 并有专门注释说明该风险) +- **问题**: `SmtpClient` 经裸 socket 做 `SSL_write`(未带 `MSG_NOSIGNAL` 语义)。若 SMTP 服务器在会话中途 RST/关闭连接(超时、限流、反垃圾策略均可能),下一次 `SSL_write` 触发 SIGPIPE → **默认行为是终止整个进程**,而非仅让该次发送失败。Python 解释器默认忽略 SIGPIPE,故 Python 版不存在此问题——本项是 C++ 重写**新引入**的缺陷。 +- **影响**: 单次发送失败升级为全站邮件服务(含 verify_email / health)整体下线;libmsg 的 `MsgClient::send` 虽带 `MSG_NOSIGNAL`,但只覆盖 pymsg 侧连接,不覆盖 SMTP 出站连接。core 守护会拉起进程,但窗口期内内存中的验证码与限流状态全部丢失。 +- **修复建议**: `main()` 开头添加 `signal(SIGPIPE, SIG_IGN)`(与 gateway 做法一致),或在 socket 层改用带错误检测的发送路径。 +- **修复记录(2026-08-09)**: `main()` 入口已加 `std::signal(SIGPIPE, SIG_IGN)`(含 `` 头)。验证:重建二进制后启动进程并向其投递 SIGPIPE,进程存活(修复前默认行为为终止);零警告编译,`--help` 正常。 + +--- + +## 四、🟡 P2 — 中危问题 + +### P2-1: `read_response` 累积缓冲无大小上限,异常 SMTP 服务器可致内存耗尽 ✅ 已修复(随 P0-1) + +- **位置**: `src/main.cpp` L567-577(`full += buf` 无界循环) +- **问题**: 循环 `SSL_read` 累积进 `std::string full`,直到解析出最终响应行或超时。若(恶意或被入侵的、由运营者配置的)SMTP 服务器持续发送不含 `"NNN "` 终止行的数据,20 秒 socket 超时窗口内(且 SSL_read 每次收到数据即重置超时,实际可远超 20 秒)`full` 无界增长。 +- **影响**: 内存耗尽型 DoS。SMTP 服务器为运营者配置的受信目标,故定 P2;修复成本极低。 +- **修复建议**: 设 `full` 上限(如 64KB,远超任何正常 SMTP 响应),超限即抛异常。 +- **修复记录(2026-08-09)**: 已随 P0-1 修复一并加入 64KB 上限,超限抛异常进入发送失败回滚路径。 + +--- + +## 五、🔵 P3 — 低危 / 健壮性问题 + +### P3-1: `verify_email` 路由 email 未校验直接入日志,存在日志注入 ✅ 已修复 +- **位置**: `src/main.cpp` L921、L927、L936 +- **问题**: `send_email` 有严格邮箱正则(P3-F5),但 `verify_email` 对任意字符串(含 `\r\n` 等控制字符)原样拼入日志消息写入 db Logs 表。gateway 传入的 email 已经过校验,直接 pymsg 调用(127.0.0.1 信任域内)则未校验。 +- **影响**: 低——攻击者需已处于本机信任域;日志按行检索时可能被伪造行干扰。 +- **修复建议**: 日志拼接前剥离控制字符,或复用 send_email 的邮箱正则做入口校验。 +- **修复记录(2026-08-09)**: 新增 `sanitize_for_log`(控制字符/DEL 替换为 `?`),`verify_email` 三处日志均经脱敏。实测:email 传 `evil\r\nINJECTED`,db Logs 表落库为 `evil??INJECTED`,无法伪造新日志行。 + +### P3-2: `read_response` 内 `std::stoi` 未捕获异常,非数字响应行导致不可诊断错误 ✅ 已修复(随 P0-1) +- **位置**: `src/main.cpp` L586 +- **问题**: 若服务器最终行第 4 字符恰为空格但前 3 字符非数字(畸形响应),`stoi` 抛 `invalid_argument`,错误路径无响应内容上下文。 +- **修复建议**: 先逐字符校验 `isdigit`,非数字按协议错误处理并携带原始行。 +- **修复记录(2026-08-09)**: 已随 P0-1 修复改为逐字符 `isdigit` 校验 + 手工解码,畸形行不再抛异常而是继续读(由 64KB 上限兜底)。 + +### P3-3: `connect_to_server` 硬编码 `AF_INET`,仅支持 IPv4 ✅ 已修复 +- **位置**: `src/main.cpp` L506 +- **问题**: 双栈环境下若 SMTP 服务器仅有 AAAA 记录则解析失败。Python `smtplib` 支持 IPv6,存在轻微行为差异。 +- **修复建议**: `AF_UNSPEC`。 +- **修复记录(2026-08-09)**: `ai_family` 改为 `AF_UNSPEC`,后续 `getaddrinfo` 结果链表按序尝试连接,IPv4/IPv6 均可达。 + +### P3-4: `generate_code` 存在模偏差 ✅ 已修复 +- **位置**: `src/main.cpp` L633(`rd() % 10`) +- **问题**: `std::random_device` 返回 32 位值,`% 10` 使数字 0-5 的出现概率比 6-9 高约 2/2³²——偏差量级可忽略,但修复成本同样可忽略。 +- **修复建议**: `std::uniform_int_distribution(0, 9)`。 +- **修复记录(2026-08-09)**: 改用 `std::uniform_int_distribution(0, 9)`,模偏差消除。 + +### P3-5: LogC 每批日志逐条同步 RPC,且每批新建 TCP 连接 ✅ 已修复(连接复用部分) +- **位置**: `inc/LogC.hpp` L80-99 +- **问题**: 与 captcha2 审计 P3-4 同源(该模式自 captcha2 移植而来):一批最多 50 条日志逐条 `call()`,且每批新建一次 db 连接。 +- **影响**: 性能开销,无安全影响;fail-safe 语义正确。 +- **修复建议**: 批量 INSERT 或连接复用(可留待 LogC 统一重构时一并处理,db2/captcha2/smtp3 共享同一模式)。 +- **修复记录(2026-08-09)**: LogC 改用底层 `detail::openConnection` + `pmsg_send/pmsg_recv` 长连接(与 `MsgClient::call` 线路格式一致,无前缀),跨批复用同一 fd,故障时关闭重建、本批丢弃(fail-safe 不变)。实测:启动与 verify_email 日志经长连接成功落库 db Logs 表。逐条 RPC 改批量 INSERT 需 db 侧协议配合,留待 LogC 统一重构(captcha2/db2 同源模式)。 + +--- + +## 六、✅ 已有安全机制(14 项) + +| # | 机制 | 位置 | +|---|------|------| +| 1 | 常数时间验证码比较(XOR 累积,防时序侧信道) | `CodeStore::const_time_eq` L307-313 | +| 2 | 验证码一次性消费,防重放 | `CodeStore::verify` L269-271 | +| 3 | 连续失败 5 次作废(防 6 位数字码在线枚举) | `CodeStore::verify` L275-279 | +| 4 | 惰性过期清理,内存无界增长防护 | `CodeStore::expire_locked` L296-304 | +| 5 | 邮箱级双窗口限流(3/h + 10/d)+ 全局熔断(100/h) | `SendLimiter` L323-426 | +| 6 | `ts_id` 精确回滚(根治 Python 版 P1-F1 的浮点比较回滚失效) | `SendLimiter::rollback` L364-377 | +| 7 | 同邮箱 60s 发送冷却,预占 + 失败回滚,并发防重 | `send_email` 路由 L792-827 | +| 8 | `std::random_device` 生成验证码(修复 Python 版 P2-F3) | `generate_code` L628-635 | +| 9 | 日志不打印验证码明文(修复 Python 版 P2-F2,对比 Python L250 明文入日志) | L884 | +| 10 | 失败响应脱敏,SMTP 错误细节仅入服务端日志(修复 Python 版 P3-F7) | L858-865 | +| 11 | 严格邮箱正则,与 gateway 对齐(修复 Python 版 P3-F5) | L752 | +| 12 | subject ≤256B / content ≤4KB 长度上限(修复 Python 版 P3-F6) | L772-789 | +| 13 | 配置白名单合并(仅识别已知 section/key),confs 强依赖阻塞等待,凭据缺失仅警告不阻断 | `merge_ini` / `validate_config` | +| 14 | pymsg 侧 EPIPE 防护继承自 libmsg `MSG_NOSIGNAL`;日志 fail-safe(db 不可用静默丢弃) | `libmsg/Msg.hpp` L234、`LogC.hpp` | + +--- + +## 七、与 Python 版审计 7 项遗留的对照 + +| 编号 | Python 版缺陷 | smtp3 落实情况 | 复核结论 | +|------|--------------|----------------|----------| +| P1-F1 | `now` 变量遮蔽 + 浮点 `==` 比较导致发送失败时限流/熔断回滚失效 | `ts_id` 单调 ID 预占,`erase_by_tsid` 精确删除 | ✅ 根治,实现优于 Python 修复方案 | +| P2-F2 | `logs.info(f"...({code})")` 验证码明文写入 Logs 表 | 成功日志仅 `"验证码发送成功"` | ✅ 已修复 | +| P2-F3 | `random.randint` 弱随机源 | `std::random_device`(P3-4 模偏差亦已修复) | ✅ 已修复 | +| P3-F4 | `==` 比较存在时序侧信道 | `const_time_eq` XOR 累积比较 | ✅ 已修复 | +| P3-F5 | 邮箱仅校验 `"@" in email` | 严格正则 `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` | ✅ 已修复(send_email 路径) | +| P3-F6 | subject/content 无长度限制 | 256B / 4KB 上限 | ✅ 已修复 | +| P3-F7 | 发送失败响应原样回传 SMTP 异常细节 | 通用文案返回客户端,细节仅入日志 | ✅ 已修复 | + +**结论**: 重写方案的"一并根除 7 项审计遗留"目标全部达成。本轮新发现的 P0/P1 均集中在重写**新增**的 `SmtpClient` 组件,与上述修复项无交集。 + +--- + +## 八、与 gateway 的协议兼容性核对 + +对照 `gateway/src/main.cpp` 的调用点(L525-540 verify、L2064-2085 注册发码、L2177-2195 重置发码)逐项核对: + +| 核对项 | gateway 期望 | smtp3 实际 | 结论 | +|--------|-------------|-----------|------| +| 传输/封装 | pymsg TCP :8008,响应 `{success, data}` | libmsg `RpcServer` 自动封装 | ✅ | +| `verify_email` 字段 | `email` / `code`,读 `data.valid` | 一致,另兼容旧字段 `input_captcha` | ✅ | +| `send_email` 字段 | `email`(+ 可选 `subject`/`content`) | 一致 | ✅ | +| 冷却拒绝 | 读 `retry_after`(int64)与 `error` 文案透传客户端 | 一致 | ✅ | +| 发码超时 | 20s(`_TIMEOUT_SMTP_SEND`) | 内部 SMTP 会话 20s 超时,边界相当 | ✅ | +| `health` | `{status, service}` | `{"status":"ok","service":"smtp"}` | ✅ | + +**有意的行为差异**(均为改进,不影响 gateway): +1. 限流(双窗口/熔断)拒绝时 smtp3 会**回滚** 60s 冷却预占,Python 版不回滚——smtp3 语义更合理(冷却只应锚定真实发送); +2. `subject`/`content` 为非字符串类型时 smtp3 回退默认值,Python 版 `str()` 强转(如 `"None"`)——smtp3 更稳健; +3. email 做 strip 与否:Python `strip()`、smtp3 不 strip——带首尾空格的邮箱两侧均无法通过后续正则/存储匹配,无实际影响。 + +--- + +## 九、继承自 Python 版 / 架构层面的已知项(不计入本轮发现) + +- **pymsg 无认证**: 与所有 pymsg 服务同源(smtp P0-2、captcha、db 同),依赖 127.0.0.1 绑定隔离,待 `SECURITY_HARDENING_PLAN_20260803.md` 内网通信加固统一解决。 +- **验证码存进程内存**: 重启即失效,单实例不可水平扩展——与 Python 版完全一致,属设计取舍。 +- **SMTP 凭据明文**: 存于 `confs/configs/smtp.ini` 并经明文 pymsg 分发,与全项目 confs 模式一致。 + +--- + +## 十、修复路线图 + +### 立即(上线前置条件) +1. ✅ **P0-1** 重写 `read_response` 为按完整行解析(最后完整行 `^\d{3} ` 判定),并加 64KB 缓冲上限(顺带修复 P2-1)——2026-08-09 完成; +2. ✅ **P0-2** `base64_encode` 三处移位改经 `uint8_t` 转无符号,补 UTF-8 编码往返单测——2026-08-09 完成; +3. 用真实 SMTP 账号完成一次**端到端发信验证**(含中文主题/正文解码核对、残缺包/多行响应/超时路径测试)——本次审计证明"编译通过 + 绑定端口"级别的验证不足以覆盖网络协议栈重写。**待凭据就绪后执行**。 + +### 短期 +4. ✅ **P1-2** `main()` 忽略 SIGPIPE——2026-08-09 完成; +5. **P1-1** TLS 证书链 + 主机名校验 + SNI + TLS≥1.2(**待修**)。 + +### 中期 +6. ✅ P3-1 ~ P3-5 全部完成(2026-08-09);逐条 INSERT 改批量仍留待 LogC 统一重构(db2/captcha2 同源)。 + +### 上线切换(全部 P0/P1 关闭后) +7. `confs/configs/smtp.ini` 填充真实凭据 → `core/init.txt` 切换 `smtp3/_run.sh` → `_inst.sh` 增加 `smtp3` → 观察 24h 日志后下线 Python 版。 + +--- + +## 十一、P0 修复验证用例清单(2026-08-09,14/14 通过) + +验证程序从修复后的 `main.cpp` 原样提取 `base64_encode` 与 `read_response` 行判定逻辑,`g++ -std=c++20 -Wall -Wextra -O2` 编译执行,用后即删;base64 基准由 Python `base64.b64encode` 计算。 + +| # | 用例 | 预期 | 结果 | +|---|------|------|------| +| 1 | base64 纯 ASCII `"Hello"` | `SGVsbG8=` | ✅ | +| 2 | base64 中文主题(生产默认值 `"DNFS 邮箱验证"`) | 与 Python 基准逐字节一致 | ✅ | +| 3 | base64 中文正文(验证码占位替换后形态) | 与 Python 基准逐字节一致 | ✅ | +| 4 | base64 全高位字节 `\xE9\xE9\xE9` | `6enp` | ✅ | +| 5 | base64 长度 1/2 填充边界 | `QQ==` / `QUI=` | ✅ | +| 6 | 单行完整响应 `"220 OK\r\n"` | 解析返回 | ✅ | +| 7 | 多行完整 EHLO 响应(含 SIZE/AUTH 续行) | 解析返回 | ✅ | +| 8 | 残缺包(无尾 CRLF) | 继续读 | ✅ | +| 9 | 残缺包(多行尾行未闭合) | 继续读 | ✅ | +| 10 | 续行结尾 `"250-AUTH\r\n"` | 继续读 | ✅ | +| 11 | 响应码不匹配(550 vs 预期 250) | 异常路径 | ✅ | +| 12 | 畸形行 `"ABC X\r\n"` | 继续读,不抛 stoi | ✅ | +| 13 | 空缓冲 | 继续读 | ✅ | +| 14 | 仅 CRLF | 继续读 | ✅ | + +配套验证:`bash _set.sh` 零警告重建 `smtp3.bin`(438KB),`--help` 正常响应。 + +--- + +## 附:实证记录 + +- 复现程序从 `main.cpp` 原样提取 `base64_encode`(L77-94)与 `read_response` 行判定逻辑(L567-599),`g++ -std=c++20 -O2` 编译执行(2026-08-09); +- base64 基准值由 `python3 -c "base64.b64encode(...)"` 计算; +- 复现文件用后即删,未入库;结论可通过上述代码段独立复核。 diff --git a/smtp3/Makefile b/smtp3/Makefile new file mode 100644 index 0000000..d729726 --- /dev/null +++ b/smtp3/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 -lssl -lcrypto -lpthread + +TARGET := smtp3.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/smtp3/_run.sh b/smtp3/_run.sh new file mode 100755 index 0000000..fd977ec --- /dev/null +++ b/smtp3/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +sleep 1 +exec ./smtp3.bin diff --git a/smtp3/_set.sh b/smtp3/_set.sh new file mode 100755 index 0000000..85a2f38 --- /dev/null +++ b/smtp3/_set.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" || exit + +make clean 2>/dev/null || true +make +echo "[smtp3] 编译完成 → ./smtp3.bin" diff --git a/smtp3/inc/LogC.hpp b/smtp3/inc/LogC.hpp new file mode 100644 index 0000000..3e08bba --- /dev/null +++ b/smtp3/inc/LogC.hpp @@ -0,0 +1,124 @@ +/* + * 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 new file mode 100644 index 0000000..54a825b --- /dev/null +++ b/smtp3/src/main.cpp @@ -0,0 +1,1013 @@ +/* + * smtp3 — C++20 邮箱验证码服务(pymsg RPC) + * + * 基于 libmsg RpcServer + OpenSSL SMTP_SSL。 + * 协议与 Python smtp_v2.py 完全兼容,gateway 零改动。 + * + * 审计修复清单(一并根除 Python 版 7 项遗留): + * P1-F1: ts_id 精确回滚替代浮点 now== 比较 + * P2-F2: 日志不打印验证码明文 + * P2-F3: std::random_device 替代 random.randint + * P3-F4: 常数时间验证码比较 + * P3-F5: 严格邮箱正则(与 gateway 对齐) + * P3-F6: subject/content 长度上限 + * P3-F7: 失败响应脱敏,SMTP 细节仅入日志 + * 2026-08-09 审计修复: + * P0-1: read_response 按完整行解析 + 64KB 上限(顺带 P2-1/P3-2) + * P0-2: base64 移位经 uint8_t 转无符号 + * P1-2: 忽略 SIGPIPE,防止 SMTP 出站写断开时进程被内核终止 + * P3-1: 日志写入前剥离控制字符(verify_email 日志注入) + * P3-3: DNS 解析改 AF_UNSPEC 支持 IPv6 + * P3-4: 验证码改 uniform_int_distribution 消除模偏差 + * P3-5: LogC 改长连接复用(见 LogC.hpp) + */ + +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#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; + +using namespace smtp3; + +// ═══════════════════════════════════════════════════════════════════════════ +// 辅助: 安全的 stoi +// ═══════════════════════════════════════════════════════════════════════════ + +static int parse_int(const std::string& key, const std::string& val) { + try { + return std::stoi(val); + } catch (const std::exception& ex) { + std::cerr << "[smtp3] 配置解析失败: " << key << " = '" << val + << "' (" << ex.what() << ")" << std::endl; + std::exit(1); + } +} + +// P3-1 修复: 日志写入前剥离控制字符,防日志注入。 +// verify_email 的 email 未经正则校验(send_email 路径已过严格正则,不受影响) +static std::string sanitize_for_log(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) + out.push_back((c >= 0x20 && c != 0x7F) ? char(c) : '?'); + return out; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 辅助: Base64 编码(用于 SMTP AUTH LOGIN) +// ═══════════════════════════════════════════════════════════════════════════ + +static const char kBase64Chars[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static std::string base64_encode(const std::string& data) { + std::string out; + out.reserve(((data.size() + 2) / 3) * 4); + for (size_t i = 0; i < data.size(); i += 3) { + // P0-2 修复: char 默认有符号,≥ 0x80 的字节会符号扩展污染整组, + // 必须先经 uint8_t 转无符号再移位 + uint32_t n = (uint32_t(uint8_t(data[i])) << 16); + if (i + 1 < data.size()) n |= (uint32_t(uint8_t(data[i + 1])) << 8); + if (i + 2 < data.size()) n |= uint32_t(uint8_t(data[i + 2])); + + out.push_back(kBase64Chars[(n >> 18) & 0x3F]); + out.push_back(kBase64Chars[(n >> 12) & 0x3F]); + out.push_back((i + 1 < data.size()) ? kBase64Chars[(n >> 6) & 0x3F] : '='); + out.push_back((i + 2 < data.size()) ? kBase64Chars[n & 0x3F] : '='); + } + return out; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. 配置结构 +// ═══════════════════════════════════════════════════════════════════════════ + +struct SmtpConfig { + // [server] + std::string host = "127.0.0.1"; + int port = 8008; + int workers = 4; + + // [smtp] + std::string smtp_server; + int smtp_port = 465; + std::string smtp_user; + std::string smtp_password; + std::string mail_from; // 空则用 smtp_user + int code_len = 6; + int code_ttl = 600; + int send_interval = 60; + int max_verify_attempts = 5; + int email_hourly_limit = 3; + int email_daily_limit = 10; + int smtp_hourly_limit = 100; + std::string default_subject = "DNFS 邮箱验证"; + int max_subject_len = 256; + 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) +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); + if (auto* v = std::getenv("WORKERS")) cfg.workers = parse_int("WORKERS", v); + if (auto* v = std::getenv("SMTP_SERVER")) cfg.smtp_server = v; + if (auto* v = std::getenv("SMTP_PORT")) cfg.smtp_port = parse_int("SMTP_PORT", v); + if (auto* v = std::getenv("SMTP_USER")) cfg.smtp_user = v; + if (auto* v = std::getenv("SMTP_PASSWORD")) cfg.smtp_password = v; + if (auto* v = std::getenv("__PASSWORD")) cfg.smtp_password = v; // 兼容旧名 + if (auto* v = std::getenv("MAIL_FROM")) cfg.mail_from = v; + if (auto* v = std::getenv("CODE_TTL")) cfg.code_ttl = parse_int("CODE_TTL", v); + if (auto* v = std::getenv("SEND_INTERVAL")) cfg.send_interval = parse_int("SEND_INTERVAL", v); + if (auto* v = std::getenv("EMAIL_HOURLY_LIMIT")) cfg.email_hourly_limit = parse_int("EMAIL_HOURLY_LIMIT", v); + if (auto* v = std::getenv("EMAIL_DAILY_LIMIT")) cfg.email_daily_limit = parse_int("EMAIL_DAILY_LIMIT", v); + if (auto* v = std::getenv("SMTP_HOURLY_LIMIT")) cfg.smtp_hourly_limit = parse_int("SMTP_HOURLY_LIMIT", v); +} + +// 配置完整性校验(SMTP 凭据缺失仅警告,不阻止启动) +static void validate_config(const SmtpConfig& cfg) { + if (cfg.host.empty()) { std::cerr << "[smtp3] 配置缺失: server.host" << std::endl; std::exit(1); } + if (cfg.port <= 0 || cfg.port > 65535) { std::cerr << "[smtp3] 配置无效: server.port" << std::endl; std::exit(1); } + if (cfg.smtp_port <= 0 || cfg.smtp_port > 65535) { std::cerr << "[smtp3] 配置无效: smtp.port" << std::endl; std::exit(1); } + if (cfg.code_len < 4 || cfg.code_len > 10) { std::cerr << "[smtp3] code_len 应在 4~10 之间" << std::endl; std::exit(1); } + if (cfg.code_ttl < 30) { std::cerr << "[smtp3] code_ttl 至少 30 秒" << std::endl; std::exit(1); } + + // SMTP 凭据缺失:警告但不退出(send_email 路由会返回错误) + std::vector missing; + if (cfg.smtp_server.empty()) missing.push_back("SMTP_SERVER"); + if (cfg.smtp_user.empty()) missing.push_back("SMTP_USER"); + if (cfg.smtp_password.empty()) missing.push_back("SMTP_PASSWORD"); + if (!missing.empty()) { + std::string list; + for (size_t i = 0; i < missing.size(); ++i) { + if (i > 0) list += ","; + list += missing[i]; + } + std::cerr << "[smtp3] 警告: SMTP 配置缺失: " << list << ",send_email 将始终失败" << std::endl; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. CodeStore — 线程安全验证码存储 +// ═══════════════════════════════════════════════════════════════════════════ + +class CodeStore { +public: + struct Entry { + std::string code; + double expires_at; // unix timestamp + int attempts = 0; + }; + + void put(const std::string& email, const std::string& code, double ttl_seconds) { + std::lock_guard lock(mtx_); + expire_locked(); + store_[email] = {code, now_unixtime() + ttl_seconds, 0}; + } + + // 校验 + 一次性消费。返回 valid;invalidated 出口参数。 + bool verify(const std::string& email, const std::string& code, + int max_attempts, bool& invalidated) { + std::lock_guard lock(mtx_); + invalidated = false; + + auto it = store_.find(email); + if (it == store_.end()) return false; + + // 过期清理 + if (now_unixtime() > it->second.expires_at) { + store_.erase(it); + return false; + } + + // 常数时间比较(P3-F4 修复) + bool match = const_time_eq(it->second.code, code); + + if (match) { + store_.erase(it); // 一次性消费 + return true; + } + + // 仅对未过期的记录计失败次数 + it->second.attempts++; + if (it->second.attempts >= max_attempts) { + store_.erase(it); + invalidated = true; + } + return false; + } + + // 发送失败回滚:删除指定邮箱的验证码 + void remove(const std::string& email) { + std::lock_guard lock(mtx_); + store_.erase(email); + } + +private: + static double now_unixtime() { + auto now = std::chrono::system_clock::now(); + auto dur = now.time_since_epoch(); + return std::chrono::duration(dur).count(); + } + + void expire_locked() { + double now = now_unixtime(); + for (auto it = store_.begin(); it != store_.end(); ) { + if (now > it->second.expires_at) + it = store_.erase(it); + else + ++it; + } + } + + // 常数时间字符串比较(抗时序侧信道) + static bool const_time_eq(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + int diff = 0; + for (size_t i = 0; i < a.size(); ++i) + diff |= a[i] ^ b[i]; + return diff == 0; + } + + std::mutex mtx_; + std::unordered_map store_; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. SendLimiter — 双窗口限流 + 全局熔断(ts_id 精确回滚) +// ═══════════════════════════════════════════════════════════════════════════ + +class SendLimiter { +public: + // 尝试消费配额。返回 true 表示允许;失败时通过 out_error 返回原因。 + // ts_id 用于失败时精确回滚预占(P1-F1 根治)。 + bool try_consume(const std::string& email, + int hourly_limit, int daily_limit, + int global_hourly_limit, + uint64_t& ts_id, + std::string& out_error) { + std::lock_guard lock(mtx_); + double now = now_unixtime(); + + // 清理滑出窗口的时间戳 + cleanup_locked(now); + + // 邮箱级配额检查 + auto& log = send_log_[email]; + if (hourly_limit > 0 && (int)log.hour.size() >= hourly_limit) { + out_error = "该邮箱每小时发送上限(" + std::to_string(hourly_limit) + "次)已达,请稍后重试"; + return false; + } + if (daily_limit > 0 && (int)log.day.size() >= daily_limit) { + out_error = "该邮箱每日发送上限(" + std::to_string(daily_limit) + "次)已达,请明日重试"; + return false; + } + + // 全局熔断 + if (global_hourly_limit > 0 && (int)global_log_.size() >= global_hourly_limit) { + out_error = "邮件服务发送量已达上限,请稍后重试"; + return false; + } + + // 预占 + ts_id = next_ts_id_++; + log.hour.push_back({ts_id, now}); + log.day.push_back({ts_id, now}); + global_log_.push_back({ts_id, now}); + return true; + } + + // 精确回滚:移除 ts_id 匹配的预占记录(P1-F1 修复) + void rollback(const std::string& email, uint64_t ts_id) { + std::lock_guard lock(mtx_); + + auto it = send_log_.find(email); + if (it != send_log_.end()) { + auto& log = it->second; + erase_by_tsid(log.hour, ts_id); + erase_by_tsid(log.day, ts_id); + // 清理空记录 + if (log.hour.empty() && log.day.empty()) + send_log_.erase(it); + } + erase_by_tsid(global_log_, ts_id); + } + +private: + struct TimedEntry { + uint64_t ts_id; + double timestamp; + }; + + struct EmailLog { + std::deque hour; + std::deque day; + }; + + static double now_unixtime() { + auto now = std::chrono::system_clock::now(); + return std::chrono::duration(now.time_since_epoch()).count(); + } + + void cleanup_locked(double now) { + // 清理各邮箱日志 + for (auto it = send_log_.begin(); it != send_log_.end(); ) { + auto& log = it->second; + while (!log.hour.empty() && now - log.hour.front().timestamp > 3600.0) + log.hour.pop_front(); + while (!log.day.empty() && now - log.day.front().timestamp > 86400.0) + log.day.pop_front(); + if (log.hour.empty() && log.day.empty()) + it = send_log_.erase(it); + else + ++it; + } + // 清理全局日志 + while (!global_log_.empty() && now - global_log_.front().timestamp > 3600.0) + global_log_.pop_front(); + } + + static void erase_by_tsid(std::deque& dq, uint64_t ts_id) { + for (auto it = dq.begin(); it != dq.end(); ++it) { + if (it->ts_id == ts_id) { + dq.erase(it); + return; // 每个 ts_id 在每条 deque 中只出现一次 + } + } + } + + std::mutex mtx_; + std::unordered_map send_log_; + std::deque global_log_; + uint64_t next_ts_id_ = 1; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. SmtpClient — SMTP_SSL 客户端 +// ═══════════════════════════════════════════════════════════════════════════ + +class SmtpClient { +public: + struct Config { + std::string server; + int port = 465; + std::string user; + std::string password; + double timeout = 20.0; + }; + + explicit SmtpClient(const Config& cfg) : cfg_(cfg) {} + + // 发送邮件。失败抛 std::runtime_error(含原始错误,由 handler 脱敏后返回)。 + void send(const std::string& from, const std::string& to, + const std::string& data) { + SSL_CTX* ctx = nullptr; + SSL* ssl = nullptr; + int sock = -1; + + try { + // 1. TCP 连接 + sock = connect_to_server(); + set_socket_timeout(sock, cfg_.timeout); + + // 2. TLS 握手 + ctx = SSL_CTX_new(TLS_client_method()); + if (!ctx) throw std::runtime_error("SSL_CTX_new 失败"); + ssl = SSL_new(ctx); + if (!ssl) throw std::runtime_error("SSL_new 失败"); + SSL_set_fd(ssl, sock); + if (SSL_connect(ssl) != 1) + throw std::runtime_error("TLS 握手失败: " + ssl_error()); + + // 3. SMTP 协议序列 + read_response(ssl, 220); // greeting + + send_cmd(ssl, "EHLO localhost"); + read_response(ssl, 250); + + send_cmd(ssl, "AUTH LOGIN"); + read_response(ssl, 334); // Username: + send_cmd(ssl, base64_encode(cfg_.user)); + read_response(ssl, 334); // Password: + send_cmd(ssl, base64_encode(cfg_.password)); + read_response(ssl, 235); // Authenticated + + send_cmd(ssl, "MAIL FROM:<" + from + ">"); + read_response(ssl, 250); + + send_cmd(ssl, "RCPT TO:<" + to + ">"); + read_response(ssl, 250); + + send_cmd(ssl, "DATA"); + read_response(ssl, 354); // Start mail input + + send_data(ssl, data + "\r\n.\r\n"); + read_response(ssl, 250); + + send_cmd(ssl, "QUIT"); + read_response(ssl, 221); + + } catch (...) { + cleanup(ssl, ctx, sock); + throw; + } + + cleanup(ssl, ctx, sock); + } + +private: + Config cfg_; + + static int connect_to_server(const std::string& server, int port) { + struct addrinfo hints = {}, *res = nullptr; + hints.ai_family = AF_UNSPEC; // P3-3: 支持 IPv4/IPv6 双栈 + hints.ai_socktype = SOCK_STREAM; + + std::string port_str = std::to_string(port); + int gai_err = getaddrinfo(server.c_str(), port_str.c_str(), &hints, &res); + if (gai_err != 0 || !res) + throw std::runtime_error("DNS 解析失败: " + server + " (" + gai_strerror(gai_err) + ")"); + + int sock = -1; + for (auto* rp = res; rp; rp = rp->ai_next) { + sock = ::socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (sock < 0) continue; + if (::connect(sock, rp->ai_addr, rp->ai_addrlen) == 0) break; + ::close(sock); + sock = -1; + } + freeaddrinfo(res); + if (sock < 0) + throw std::runtime_error("无法连接到 " + server + ":" + port_str); + return sock; + } + + int connect_to_server() { + return connect_to_server(cfg_.server, cfg_.port); + } + + static void set_socket_timeout(int sock, double seconds) { + struct timeval tv; + tv.tv_sec = static_cast(seconds); + tv.tv_usec = static_cast((seconds - tv.tv_sec) * 1e6); + ::setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + ::setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + } + + static void send_cmd(SSL* ssl, const std::string& cmd) { + std::string line = cmd + "\r\n"; + int total = 0; + while (total < (int)line.size()) { + int n = SSL_write(ssl, line.data() + total, line.size() - total); + if (n <= 0) { + int err = SSL_get_error(ssl, n); + throw std::runtime_error("SSL_write 失败: " + ssl_error_str(err)); + } + total += n; + } + } + + static void send_data(SSL* ssl, const std::string& data) { + int total = 0; + while (total < (int)data.size()) { + int n = SSL_write(ssl, data.data() + total, data.size() - total); + if (n <= 0) { + int err = SSL_get_error(ssl, n); + throw std::runtime_error("SSL_write 失败: " + ssl_error_str(err)); + } + total += n; + } + } + + // 读取 SMTP 响应,校验最终响应码。 + // 处理 RFC 5321 多行响应:250-SIZE\r\n250-AUTH\r\n250 OK + // P0-1 修复: 按"完整行"(以 CRLF 结尾)解析,最后一个完整行的第 4 + // 字符为空格即最终行('-' 为续行);缓冲区尾部未闭合的数据视为未读完。 + // 响应码逐字符校验数字(顺带修复 P3-2 stoi 异常),累计上限 64KB(P2-1)。 + static std::string read_response(SSL* ssl, int expected_code) { + std::string full; + for (;;) { + if (full.size() > 65536) + throw std::runtime_error("SMTP 响应超过 64KB 上限"); + + char buf[4096]; + int n = SSL_read(ssl, buf, sizeof(buf) - 1); + if (n <= 0) { + int err = SSL_get_error(ssl, n); + throw std::runtime_error("SSL_read 失败: " + ssl_error_str(err)); + } + full.append(buf, n); + + // 定位最后一个完整行(以 CRLF 结尾) + size_t pos = 0; + size_t last_line_start = std::string::npos; + size_t last_line_end = 0; + for (;;) { + auto crlf = full.find("\r\n", pos); + if (crlf == std::string::npos) break; + last_line_start = pos; + last_line_end = crlf; + pos = crlf + 2; + } + if (last_line_start == std::string::npos) continue; // 尚无完整行 + + // 格式: "NNN "(最终行) 或 "NNN-"(续行) + size_t line_len = last_line_end - last_line_start; + if (line_len >= 4 && full[last_line_start + 3] == ' ' && + std::isdigit((unsigned char)full[last_line_start]) && + std::isdigit((unsigned char)full[last_line_start + 1]) && + std::isdigit((unsigned char)full[last_line_start + 2])) { + int code = (full[last_line_start] - '0') * 100 + + (full[last_line_start + 1] - '0') * 10 + + (full[last_line_start + 2] - '0'); + if (code != expected_code) { + // 去掉 \r\n 便于日志 + std::string clean = full; + for (auto& c : clean) if (c == '\r' || c == '\n') c = ' '; + throw std::runtime_error("SMTP 响应码 " + std::to_string(code) + + " (预期 " + std::to_string(expected_code) + "): " + clean); + } + return full; + } + // "NNN-" 续行或畸形行 → 继续读(畸形行由 64KB 上限兜底) + } + } + + static std::string ssl_error() { + char buf[256]; + ERR_error_string_n(ERR_get_error(), buf, sizeof(buf)); + return std::string(buf); + } + + static std::string ssl_error_str(int ssl_err) { + switch (ssl_err) { + case SSL_ERROR_ZERO_RETURN: return "连接被对端关闭"; + case SSL_ERROR_SYSCALL: return "syscall 错误: " + std::string(strerror(errno)); + case SSL_ERROR_SSL: return "SSL 协议错误: " + ssl_error(); + default: return "SSL 错误码 " + std::to_string(ssl_err); + } + } + + static void cleanup(SSL* ssl, SSL_CTX* ctx, int sock) { + if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); } + if (ctx) SSL_CTX_free(ctx); + if (sock >= 0) ::close(sock); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. generate_code() — 安全随机验证码 +// ═══════════════════════════════════════════════════════════════════════════ + +static std::string generate_code(int len) { + std::random_device rd; + // P3-4: 用均匀分布消除 rd() % 10 的模偏差 + std::uniform_int_distribution digit(0, 9); + std::string code; + code.reserve(len); + for (int i = 0; i < len; ++i) + code.push_back('0' + digit(rd)); + return code; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. build_mime() — MIME 邮件拼装 +// ═══════════════════════════════════════════════════════════════════════════ + +static std::string build_mime(const std::string& from, + const std::string& to, + const std::string& subject, + const std::string& body_plain) { + std::string subject_b64 = base64_encode(subject); + std::string body_b64 = base64_encode(body_plain); + + // RFC 2045: base64 每行最多 76 字符 + std::string body_wrapped; + for (size_t i = 0; i < body_b64.size(); i += 76) { + if (!body_wrapped.empty()) body_wrapped += "\r\n"; + body_wrapped += body_b64.substr(i, 76); + } + + std::ostringstream os; + os << "From: " << from << "\r\n" + << "To: " << to << "\r\n" + << "Subject: =?UTF-8?B?" << subject_b64 << "?=\r\n" + << "MIME-Version: 1.0\r\n" + << "Content-Type: text/plain; charset=UTF-8\r\n" + << "Content-Transfer-Encoding: base64\r\n" + << "\r\n" + << body_wrapped; + return os.str(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. 全局状态 +// ═══════════════════════════════════════════════════════════════════════════ + +static SmtpConfig g_config; +static CodeStore g_code_store; +static SendLimiter g_limiter; +// 发送冷却(同邮箱 60s 间隔): email → 上次成功发送时间戳 +static std::unordered_map g_cooldown; +static std::mutex g_cooldown_mtx; +static auto& g_log = LogC::instance(); + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. main() — RpcServer 启动 +// ═══════════════════════════════════════════════════════════════════════════ + +int main(int argc, char* argv[]) { + try { + // P1-2: 忽略 SIGPIPE(与 gateway 一致)。SMTP 服务器中途断开连接时, + // SSL_write 应返回错误走发送失败回滚路径,而不是终止整个进程 + 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)"); + 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; + } + + // ── 配置加载:confs → env → 命令行 ── + 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("port")) g_config.port = vm["port"].as(); + if (vm.count("workers")) g_config.workers = vm["workers"].as(); + + validate_config(g_config); + + // 默认验证码有效期的文案 + std::string default_content = + "DNFS: 您的邮箱验证码为 {code}," + + std::to_string(g_config.code_ttl / 60) + " 分钟内有效。"; + + 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; + + // ── 启动 RpcServer ── + g_log.info("smtp3", "启动中... 监听 tcp://" + g_config.host + ":" + std::to_string(g_config.port)); + + pmc::net::RpcServer rpc(g_config.host, g_config.port, + pmc::net::MsgServerConfig{.workers = g_config.workers}); + + // ═══════════════════════════════════════════════════════════════ + // 路由: send_email + // ═══════════════════════════════════════════════════════════════ + rpc.add_route("send_email", [&](const json::object& req) -> json::value { + // 取 email + auto email_it = req.find("email"); + if (email_it == req.end() || !email_it->value().is_string()) { + json::object data; + data["sent"] = false; + data["error"] = "invalid email"; + return data; + } + std::string email = json::value_to(email_it->value()); + + // P3-F5: 严格邮箱正则(与 gateway 对齐) + static const std::regex email_re(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)"); + if (!std::regex_match(email, email_re)) { + json::object data; + data["sent"] = false; + data["error"] = "invalid email"; + return data; + } + + // SMTP 配置完整性 + if (g_config.smtp_server.empty() || g_config.smtp_user.empty() || g_config.smtp_password.empty()) { + g_log.error("smtp3", email + ": SMTP 配置缺失,无法发送"); + json::object data; + data["sent"] = false; + data["error"] = "SMTP 服务未配置"; + return data; + } + + // P3-F6: subject/content 长度限制 + std::string subject = g_config.default_subject; + std::string content = default_content; + if (auto it = req.find("subject"); it != req.end() && it->value().is_string()) { + subject = json::value_to(it->value()); + if (subject.size() > (size_t)g_config.max_subject_len) { + json::object data; + data["sent"] = false; + data["error"] = "subject too long"; + return data; + } + } + if (auto it = req.find("content"); it != req.end() && it->value().is_string()) { + content = json::value_to(it->value()); + if (content.size() > (size_t)g_config.max_content_len) { + json::object data; + data["sent"] = false; + data["error"] = "content too long"; + return data; + } + } + + // 发送冷却 + { + std::lock_guard lock(g_cooldown_mtx); + double now = std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()).count(); + auto cd_it = g_cooldown.find(email); + if (cd_it != g_cooldown.end()) { + double remain = g_config.send_interval - (now - cd_it->second); + if (remain > 0) { + int wait = static_cast(remain) + 1; + g_log.info("smtp3", email + ": 发送冷却中,剩余 " + std::to_string(wait) + "s,已拒绝"); + json::object data; + data["sent"] = false; + data["retry_after"] = wait; + data["error"] = "发送过于频繁,请 " + std::to_string(wait) + " 秒后重试"; + return data; + } + } + g_cooldown[email] = now; // 预占(失败时回滚) + } + + // 双窗口限流 + 全局熔断预占 + uint64_t ts_id = 0; + std::string limit_err; + if (!g_limiter.try_consume(email, + g_config.email_hourly_limit, g_config.email_daily_limit, + g_config.smtp_hourly_limit, ts_id, limit_err)) { + // 回滚冷却预占 + { + std::lock_guard lock(g_cooldown_mtx); + g_cooldown.erase(email); + } + json::object data; + data["sent"] = false; + data["error"] = limit_err; + return data; + } + + // 生成验证码 + std::string code = generate_code(g_config.code_len); + content = std::regex_replace(content, std::regex("\\{code\\}"), code); + + // 写入 CodeStore + g_code_store.put(email, code, g_config.code_ttl); + + // SMTP 发送 + try { + SmtpClient::Config smtp_cfg; + smtp_cfg.server = g_config.smtp_server; + smtp_cfg.port = g_config.smtp_port; + smtp_cfg.user = g_config.smtp_user; + smtp_cfg.password = g_config.smtp_password; + smtp_cfg.timeout = 20.0; + + SmtpClient smtp(smtp_cfg); + std::string mime = build_mime(mail_from, email, subject, content); + smtp.send(mail_from, email, mime); + + } catch (const std::exception& err) { + // 发送失败:回滚 CodeStore、冷却预占、限流预占 + g_code_store.remove(email); + { + std::lock_guard lock(g_cooldown_mtx); + g_cooldown.erase(email); + } + g_limiter.rollback(email, ts_id); + + std::string smtp_detail = err.what(); + g_log.error("smtp3", email + ": SMTP 发送失败(" + smtp_detail + ")"); + + // P3-F7: 返回通用文案,SMTP 细节仅入日志 + json::object data; + data["sent"] = false; + data["error"] = "验证码发送失败,请稍后重试"; + return data; + } + + // 发送成功:刷新冷却时间戳(保持预占即可,已在检查时占位) + // 惰性清理过期冷却记录 + { + std::lock_guard lock(g_cooldown_mtx); + double now = std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()).count(); + g_cooldown[email] = now; + for (auto it = g_cooldown.begin(); it != g_cooldown.end(); ) { + if (now - it->second > g_config.send_interval) + it = g_cooldown.erase(it); + else + ++it; + } + } + + // P2-F2: 日志不打印验证码明文 + g_log.info("smtp3", email + ": 验证码发送成功"); + + json::object data; + data["sent"] = true; + data["ttl"] = g_config.code_ttl; + return data; + }); + + // ═══════════════════════════════════════════════════════════════ + // 路由: verify_email + // ═══════════════════════════════════════════════════════════════ + rpc.add_route("verify_email", [&](const json::object& req) -> json::value { + // 取 email + auto email_it = req.find("email"); + std::string email; + if (email_it != req.end() && email_it->value().is_string()) + email = json::value_to(email_it->value()); + + // 取 code(兼容旧字段 input_captcha) + std::string code; + if (auto it = req.find("code"); it != req.end() && it->value().is_string()) + code = json::value_to(it->value()); + else if (auto it = req.find("input_captcha"); it != req.end() && it->value().is_string()) + code = json::value_to(it->value()); + + if (email.empty() || code.empty()) { + json::object data; + data["valid"] = false; + data["error"] = "email and code required"; + return data; + } + + bool invalidated = false; + bool valid = g_code_store.verify(email, code, + g_config.max_verify_attempts, invalidated); + + // P3-1: email 未过正则,入日志前剥离控制字符 + std::string safe_email = sanitize_for_log(email); + + if (valid) { + g_log.info("smtp3", safe_email + ": 验证通过"); + json::object data; + data["valid"] = true; + return data; + } + if (invalidated) { + g_log.info("smtp3", safe_email + ": 连续校验失败 " + + std::to_string(g_config.max_verify_attempts) + " 次,验证码已作废"); + json::object data; + data["valid"] = false; + data["invalidated"] = true; + data["error"] = "验证码连续错误 " + + std::to_string(g_config.max_verify_attempts) + " 次,已失效,请重新获取"; + return data; + } + g_log.info("smtp3", safe_email + ": 验证失败"); + json::object data; + data["valid"] = false; + data["error"] = "invalid or expired code"; + return data; + }); + + // ═══════════════════════════════════════════════════════════════ + // 路由: health + // ═══════════════════════════════════════════════════════════════ + rpc.add_route("health", [](const json::object&) -> json::value { + json::object data; + data["status"] = "ok"; + data["service"] = "smtp"; + return data; + }); + + std::cerr << "[smtp3] 监听 tcp://" << g_config.host << ":" << g_config.port << std::endl; + g_log.info("smtp3", "开始监听 tcp://" + g_config.host + ":" + std::to_string(g_config.port)); + + rpc.start(); // 阻塞 + + } catch (const std::exception& e) { + std::cerr << "[smtp3] 致命错误: " << e.what() << std::endl; + g_log.error("smtp3", std::string("致命错误: ") + e.what()); + return 1; + } + + return 0; +}