- 新增 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
1014 lines
38 KiB
C++
1014 lines
38 KiB
C++
/*
|
||
* 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 <us/Msg.hpp>
|
||
#include <us/RpcServer.hpp>
|
||
#include <us/RpcClient.hpp>
|
||
#include <us/AsyncQueue.hpp>
|
||
|
||
#include <boost/json.hpp>
|
||
#include <boost/program_options.hpp>
|
||
|
||
#include <openssl/ssl.h>
|
||
#include <openssl/err.h>
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cctype>
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <csignal>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <deque>
|
||
#include <iomanip>
|
||
#include <iostream>
|
||
#include <mutex>
|
||
#include <random>
|
||
#include <regex>
|
||
#include <sstream>
|
||
#include <stdexcept>
|
||
#include <string>
|
||
#include <unordered_map>
|
||
#include <vector>
|
||
|
||
#include <arpa/inet.h>
|
||
#include <netdb.h>
|
||
#include <netinet/in.h>
|
||
#include <sys/socket.h>
|
||
#include <unistd.h>
|
||
|
||
#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<std::string>(*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<std::string> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<double>(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<std::string, Entry> 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<std::mutex> 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<std::mutex> 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<TimedEntry> hour;
|
||
std::deque<TimedEntry> day;
|
||
};
|
||
|
||
static double now_unixtime() {
|
||
auto now = std::chrono::system_clock::now();
|
||
return std::chrono::duration<double>(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<TimedEntry>& 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<std::string, EmailLog> send_log_;
|
||
std::deque<TimedEntry> 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<time_t>(seconds);
|
||
tv.tv_usec = static_cast<suseconds_t>((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<int> 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<std::string, double> 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<std::string>(), "监听地址(覆盖 confs)")
|
||
("port,p", po::value<int>(), "监听端口(覆盖 confs)")
|
||
("workers,w", po::value<int>(), "工作线程数(覆盖 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<std::string>();
|
||
if (vm.count("port")) g_config.port = vm["port"].as<int>();
|
||
if (vm.count("workers")) g_config.workers = vm["workers"].as<int>();
|
||
|
||
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<std::string>(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<std::string>(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<std::string>(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<std::mutex> lock(g_cooldown_mtx);
|
||
double now = std::chrono::duration<double>(
|
||
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<int>(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<std::mutex> 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<std::mutex> 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<std::mutex> lock(g_cooldown_mtx);
|
||
double now = std::chrono::duration<double>(
|
||
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<std::string>(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<std::string>(it->value());
|
||
else if (auto it = req.find("input_captcha"); it != req.end() && it->value().is_string())
|
||
code = json::value_to<std::string>(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;
|
||
}
|