feat: captcha C++20 重写 + 安全审计修复 (P0/P1/P2/P3, 10/11 已修复)

captcha2 — C++20 验证码服务(FreeType + stb_image_write):
- 完整实现: 图片生成、CaptchaStore、RateLimiter、pymsg RPC 路由
- 协议完全兼容 Python 原版,gateway 零改动

审计修复 (captcha2_SECURITY_AUDIT_20260809.md):
- P0-1: 存储答案小写化 (对齐 Python 版语义)
- P1-1: gateway 按 IP 限流 10/min + captcha2 全局限流 600/min 兜底
- P2-1: generate_text mt19937 → std::random_device
- P2-2: ::tolower UB → unsigned char lambda
- P3-1: parse_int 辅助函数, stoi 异常可诊断
- P3-2: 显式 #include <iomanip>
- P3-3: 补齐 CAPTCHA_LEN/TTL/RATE_LIMIT 环境变量
- P3-4: LogC 日志超时 5.0→1.0s
- P3-5: 限流/满容抛异常, gateway 映射 429/503
- P3-6: 清理死代码

集成:
- core/init.txt: captcha → captcha2
- confs configs/captcha.ini: 新增 max_store/rate_limit_per_min
- docs: 开发方案、安全审计报告、交叉引用更新
This commit is contained in:
qing committed 2026-08-09 04:26:09 +00:00
1 parent 29e4f96061
commit 546394b681
15 files changed
+3959 -4

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
CXX := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -O2
INCS := -I./inc -I/usr/include/freetype2
LIBS := -lus_msg -lboost_json -lboost_program_options -lfreetype -lpthread -lm
TARGET := captcha2.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)
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
sleep 1
exec ./captcha2.bin
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")" || exit
make clean 2>/dev/null || true
make
echo "[captcha2] 编译完成 → ./captcha2.bin"
+111
View File
@@ -0,0 +1,111 @@
/*
* LogC.hpp — captcha2 异步日志客户端(简化版)
*
* 基于 libmsg AsyncQueue,异步批量推送日志到 db:8005 的 Logs 表。
* - 内存队列 + 后台线程攒批(满 50 条或 2 秒 flush)
* - fail-safe:db 不可用时静默丢弃
* - API: info() / warning() / error()
*/
#pragma once
#include <us/Msg.hpp>
#include <us/AsyncQueue.hpp>
#include <boost/json.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <chrono>
#include <cstdlib>
namespace captcha2 {
struct LogEntry {
std::string ts;
std::string service;
std::string level;
std::string message;
std::string trace_id;
std::string extra;
};
class LogC {
public:
static 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<pmc::net::AsyncQueue<LogEntry>>(
[this](std::vector<LogEntry>&& 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<LogEntry>&&) {
std::cerr << "[captcha2/log] send failed: " << e.what() << std::endl;
});
queue_->start();
}
~LogC() { if (queue_) queue_->shutdown(); }
void push(const std::string& svc, const std::string& level, const std::string& msg) {
auto now = std::chrono::system_clock::now();
auto t = std::chrono::system_clock::to_time_t(now);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", std::gmtime(&t));
LogEntry e;
e.ts = buf;
e.service = svc;
e.level = level;
e.message = msg;
queue_->push(std::move(e));
}
void send_batch(std::vector<LogEntry>&& batch) {
try {
// P3-4: 复用单连接发送整批日志,超时 1.0s(日志非关键路径,db2 慢时不阻塞 flush 线程过久)
pmc::net::MsgClient client(db_host_, db_port_, "captcha2-log");
for (auto& e : batch) {
boost::json::object vals;
vals["ts"] = e.ts;
vals["service"] = e.service;
vals["level"] = e.level;
vals["message"] = e.message;
vals["trace_id"] = e.trace_id.empty() ? nullptr : boost::json::value(e.trace_id);
vals["extra"] = e.extra.empty() ? nullptr : boost::json::value(e.extra);
boost::json::object req;
req["_route"] = "execute";
req["action"] = "insert";
req["table"] = "Logs";
req["values"] = vals;
client.call(boost::json::serialize(req), 1.0);
}
} catch (const std::exception& e) {
std::cerr << "[captcha2/log] batch drop: " << e.what() << std::endl;
}
}
std::string db_host_;
int db_port_;
std::unique_ptr<pmc::net::AsyncQueue<LogEntry>> queue_;
};
} // namespace captcha2
File diff suppressed because it is too large. Load diff
+676
View File
@@ -0,0 +1,676 @@
/*
* captcha2 — C++20 验证码服务(pymsg RPC)
*
* 基于 libmsg RpcServer + FreeType 字体渲染 + stb_image_write PNG 编码。
* 协议与 Python captcha 完全兼容,gateway 零改动。
*/
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#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 <ft2build.h>
#include FT_FREETYPE_H
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <random>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>
#include "LogC.hpp"
namespace json = boost::json;
namespace po = boost::program_options;
using namespace captcha2;
// ═══════════════════════════════════════════════════════════════════════════
// 1. 配置结构
// ═══════════════════════════════════════════════════════════════════════════
// P3-1: 安全的 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 << "[captcha2] 配置解析失败: " << key << " = '" << val
<< "' (" << ex.what() << ")" << std::endl;
std::exit(1);
}
}
struct CaptchaConfig {
std::string host = "127.0.0.1";
int port = 8009;
int workers = 4;
int len = 4;
int ttl = 300;
std::string font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
int max_store = 10000;
int rate_limit_per_min = 600; // P1-1: 全局限流仅作最后防线,主要限流由 gateway 按 IP 负责
};
// 从 confs 拉取 captcha.ini 全文,阻塞等待 confs 就绪
static std::string fetch_config_from_confs(const std::string& confs_host, int confs_port) {
pmc::net::RpcClient client(confs_host, confs_port, "captcha2",
/*timeout=*/5.0, /*retry_on_refused=*/true);
try {
auto data = client.call("get", {{"name", "captcha.ini"}});
auto* content = data.as_object().if_contains("content");
if (!content || !content->is_string()) {
std::cerr << "[captcha2] confs 返回的 captcha.ini 无 content 字段" << std::endl;
std::exit(1);
}
return json::value_to<std::string>(*content);
} catch (const std::exception& e) {
std::cerr << "[captcha2] 致命错误: 从 confs 获取 captcha.ini 失败: " << e.what() << std::endl;
std::exit(1);
}
}
// 把 ini 文本合并进 config(仅覆盖已知 section/key,白名单策略)
static void merge_ini(CaptchaConfig& cfg, const std::string& text) {
std::istringstream stream(text);
std::string line, section;
while (std::getline(stream, line)) {
// trim
auto start = line.find_first_not_of(" \t\r");
if (start == std::string::npos || line[start] == '#' || line[start] == ';')
continue;
auto end = line.find_last_not_of(" \t\r");
line = line.substr(start, end - start + 1);
if (line.front() == '[' && line.back() == ']') {
section = line.substr(1, line.size() - 2);
continue;
}
auto eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
// trim key
auto ke = key.find_last_not_of(" \t");
if (ke != std::string::npos) key = key.substr(0, ke + 1);
auto ks = key.find_first_not_of(" \t");
if (ks != std::string::npos) key = key.substr(ks);
// trim val
auto vs = val.find_first_not_of(" \t");
if (vs != std::string::npos) val = val.substr(vs);
auto ve = val.find_last_not_of(" \t");
if (ve != std::string::npos) val = val.substr(0, ve + 1);
if (section == "server") {
if (key == "host") cfg.host = val;
else if (key == "port") cfg.port = parse_int(key, val);
else if (key == "workers") cfg.workers = parse_int(key, val);
} else if (section == "captcha") {
if (key == "len") cfg.len = parse_int(key, val);
else if (key == "ttl") cfg.ttl = parse_int(key, val);
else if (key == "font_path") cfg.font_path = val;
else if (key == "max_store") cfg.max_store = parse_int(key, val);
else if (key == "rate_limit_per_min") cfg.rate_limit_per_min = parse_int(key, val);
}
}
}
// 环境变量覆盖(最高优先级,Docker 兼容)
static void apply_env_overrides(CaptchaConfig& cfg) {
if (auto* v = std::getenv("LISTEN_ADDR")) cfg.host = v;
if (auto* v = std::getenv("LISTEN_PORT")) cfg.port = parse_int("LISTEN_PORT", v);
if (auto* v = std::getenv("WORKERS")) cfg.workers = parse_int("WORKERS", v);
if (auto* v = std::getenv("CAPTCHA_FONT_PATH")) cfg.font_path = v;
if (auto* v = std::getenv("CAPTCHA_MAX_STORE")) cfg.max_store = parse_int("CAPTCHA_MAX_STORE", v);
// P3-3: 补齐 captcha 段环境变量覆盖
if (auto* v = std::getenv("CAPTCHA_LEN")) cfg.len = parse_int("CAPTCHA_LEN", v);
if (auto* v = std::getenv("CAPTCHA_TTL")) cfg.ttl = parse_int("CAPTCHA_TTL", v);
if (auto* v = std::getenv("CAPTCHA_RATE_LIMIT")) cfg.rate_limit_per_min = parse_int("CAPTCHA_RATE_LIMIT", v);
}
// 配置完整性校验
static void validate_config(const CaptchaConfig& cfg) {
if (cfg.host.empty()) { std::cerr << "[captcha2] 配置缺失: server.host" << std::endl; std::exit(1); }
if (cfg.port <= 0 || cfg.port > 65535) { std::cerr << "[captcha2] 配置无效: server.port" << std::endl; std::exit(1); }
if (cfg.workers < 0) { std::cerr << "[captcha2] workers 不能为负数" << std::endl; std::exit(1); }
if (cfg.len < 2 || cfg.len > 8) { std::cerr << "[captcha2] captcha.len 应在 2~8 之间" << std::endl; std::exit(1); }
if (cfg.ttl < 10) { std::cerr << "[captcha2] captcha.ttl 至少 10 秒" << std::endl; std::exit(1); }
if (cfg.max_store <= 0) { std::cerr << "[captcha2] captcha.max_store 必须为正整数" << std::endl; std::exit(1); }
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. Base64 编码
// ═══════════════════════════════════════════════════════════════════════════
static const char kBase64Chars[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static std::string base64_encode(const std::vector<uint8_t>& data) {
std::string out;
out.reserve(((data.size() + 2) / 3) * 4);
for (size_t i = 0; i < data.size(); i += 3) {
uint32_t n = (uint32_t(data[i]) << 16);
if (i + 1 < data.size()) n |= (uint32_t(data[i + 1]) << 8);
if (i + 2 < data.size()) n |= uint32_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;
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. CaptchaStore — 线程安全 Token 存储
// ═══════════════════════════════════════════════════════════════════════════
class CaptchaStore {
public:
struct Entry {
std::string answer;
std::chrono::steady_clock::time_point created;
};
std::string generate(const std::string& answer, int ttl_seconds, int max_size) {
std::lock_guard<std::mutex> lock(mtx_);
expire_locked(ttl_seconds);
if ((int)store_.size() >= max_size)
throw std::runtime_error("too many pending captchas");
// 生成 256-bit 随机 Token(64 字符 hex)
std::array<uint8_t, 32> rand_bytes;
std::random_device rd;
for (auto& b : rand_bytes) b = static_cast<uint8_t>(rd());
std::ostringstream oss;
oss << std::hex;
for (auto b : rand_bytes) oss << std::setw(2) << std::setfill('0') << (int)b;
std::string token = oss.str();
store_[token] = {answer, std::chrono::steady_clock::now()};
return token;
}
// 校验 + 一次性消费;返回 nullopt 表示 token 无效;否则返回 {answer_correct: bool}
struct VerifyResult { bool valid; bool token_found; };
VerifyResult verify(const std::string& token, const std::string& answer, int ttl_seconds) {
std::lock_guard<std::mutex> lock(mtx_);
auto it = store_.find(token);
if (it == store_.end()) return {false, false};
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::steady_clock::now() - it->second.created).count();
if (elapsed > ttl_seconds) {
store_.erase(it);
return {false, false};
}
auto stored_answer = it->second.answer;
store_.erase(it); // 一次性消费
// 恒定时间比较
bool match = (answer.size() == stored_answer.size());
if (match) {
int diff = 0;
for (size_t i = 0; i < answer.size(); ++i)
diff |= answer[i] ^ stored_answer[i];
match = (diff == 0);
}
return {match, true};
}
size_t size() const {
std::lock_guard<std::mutex> lock(mtx_);
return store_.size();
}
private:
void expire_locked(int ttl_seconds) {
auto now = std::chrono::steady_clock::now();
for (auto it = store_.begin(); it != store_.end(); ) {
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - it->second.created).count();
if (elapsed > ttl_seconds)
it = store_.erase(it);
else
++it;
}
}
mutable std::mutex mtx_;
std::unordered_map<std::string, Entry> store_;
};
// ═══════════════════════════════════════════════════════════════════════════
// 4. generate_text() — 安全随机验证码文本
// ═══════════════════════════════════════════════════════════════════════════
static std::string generate_text(int len) {
static const char kChars[] =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
static constexpr size_t kNumChars = sizeof(kChars) - 1;
// 使用 std::random_device 逐字符生成(非确定性 CSPRNG),
// 与 Token 生成策略对齐,消除 mt19937 可预测性风险(P2-1)
std::random_device rd;
std::string text;
text.reserve(len);
for (int i = 0; i < len; ++i)
text.push_back(kChars[rd() % kNumChars]);
return text;
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. 图片生成 — FreeType + 像素操作 + stb PNG 编码
// ═══════════════════════════════════════════════════════════════════════════
struct RGB {
uint8_t r, g, b;
};
static constexpr int kImgWidth = 160;
static constexpr int kImgHeight = 50;
static constexpr int kImgChannels = 3;
// 线程本地随机数生成器(用于图片生成中的各种随机元素)
static thread_local std::mt19937 t_rng(std::random_device{}());
static RGB random_color() {
std::uniform_int_distribution<int> d(0, 255);
return {static_cast<uint8_t>(d(t_rng)), static_cast<uint8_t>(d(t_rng)), static_cast<uint8_t>(d(t_rng))};
}
static inline uint8_t* pixel_at(std::vector<uint8_t>& pix, int x, int y) {
return &pix[(y * kImgWidth + x) * kImgChannels];
}
// 最近邻插值旋转灰度位图(expand 模式),返回旋转后的位图
static std::vector<uint8_t> rotate_bitmap(
const FT_Bitmap& src, double angle_deg,
int& out_w, int& out_h)
{
double rad = angle_deg * M_PI / 180.0;
double cos_a = std::cos(rad), sin_a = std::sin(rad);
int sw = static_cast<int>(src.width);
int sh = static_cast<int>(src.rows);
// expand: 计算旋转后包围盒
double corners[4][2] = {
{0, 0}, {double(sw), 0}, {double(sw), double(sh)}, {0, double(sh)}
};
double min_x = 1e9, max_x = -1e9, min_y = 1e9, max_y = -1e9;
for (auto& c : corners) {
double rx = c[0] * cos_a - c[1] * sin_a;
double ry = c[0] * sin_a + c[1] * cos_a;
if (rx < min_x) min_x = rx;
if (rx > max_x) max_x = rx;
if (ry < min_y) min_y = ry;
if (ry > max_y) max_y = ry;
}
out_w = static_cast<int>(std::ceil(max_x - min_x));
out_h = static_cast<int>(std::ceil(max_y - min_y));
std::vector<uint8_t> out(out_w * out_h, 0);
for (int dy = 0; dy < out_h; ++dy) {
for (int dx = 0; dx < out_w; ++dx) {
// 逆变换:输出坐标 → 源坐标
double sx = (dx + min_x) * cos_a + (dy + min_y) * sin_a;
double sy = -(dx + min_x) * sin_a + (dy + min_y) * cos_a;
int six = static_cast<int>(std::floor(sx));
int siy = static_cast<int>(std::floor(sy));
if (six >= 0 && six < sw && siy >= 0 && siy < sh) {
// 最近邻
out[dy * out_w + dx] = src.buffer[siy * src.pitch + six];
}
}
}
return out;
}
// Alpha 合成旋转后的字符位图到主画布(灰度值 = alpha,0=透明, 255=不透明)
static void composite_char(
std::vector<uint8_t>& pixels,
const std::vector<uint8_t>& glyph, int gw, int gh,
int dst_x, int dst_y, const RGB& color)
{
for (int gy = 0; gy < gh; ++gy) {
for (int gx = 0; gx < gw; ++gx) {
uint8_t alpha = glyph[gy * gw + gx];
if (alpha == 0) continue;
int px = dst_x + gx;
int py = dst_y + gy;
if (px < 0 || px >= kImgWidth || py < 0 || py >= kImgHeight) continue;
auto* p = pixel_at(pixels, px, py);
float a = alpha / 255.0f;
p[0] = static_cast<uint8_t>(p[0] * (1 - a) + color.r * a);
p[1] = static_cast<uint8_t>(p[1] * (1 - a) + color.g * a);
p[2] = static_cast<uint8_t>(p[2] * (1 - a) + color.b * a);
}
}
}
// Bresenham 画线
static void draw_line(std::vector<uint8_t>& pixels, int x0, int y0, int x1, int y1, const RGB& color) {
int dx = std::abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -std::abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy;
while (true) {
if (x0 >= 0 && x0 < kImgWidth && y0 >= 0 && y0 < kImgHeight) {
auto* p = pixel_at(pixels, x0, y0);
p[0] = color.r; p[1] = color.g; p[2] = color.b;
}
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
// 设置单个像素
static void set_pixel(std::vector<uint8_t>& pixels, int x, int y, const RGB& color) {
if (x >= 0 && x < kImgWidth && y >= 0 && y < kImgHeight) {
auto* p = pixel_at(pixels, x, y);
p[0] = color.r; p[1] = color.g; p[2] = color.b;
}
}
// 主生成函数:返回 PNG 字节流
static std::vector<uint8_t> generate_image(const std::string& text, const std::string& font_path) {
// 1. 创建 RGB 画布 + 随机背景色
std::vector<uint8_t> pixels(kImgWidth * kImgHeight * kImgChannels);
auto bg = random_color();
for (int i = 0; i < kImgWidth * kImgHeight; ++i) {
pixels[i * 3 + 0] = bg.r;
pixels[i * 3 + 1] = bg.g;
pixels[i * 3 + 2] = bg.b;
}
// 2. 初始化 FreeType
FT_Library ft;
if (FT_Init_FreeType(&ft) != 0)
throw std::runtime_error("FreeType 初始化失败");
FT_Face face;
// 尝试指定字体路径,失败回退 DejaVu,再失败退出
if (FT_New_Face(ft, font_path.c_str(), 0, &face) != 0) {
std::cerr << "[captcha2] 字体 " << font_path << " 加载失败,尝试默认 DejaVu" << std::endl;
if (FT_New_Face(ft, "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0, &face) != 0) {
FT_Done_FreeType(ft);
throw std::runtime_error("无法加载任何 TrueType 字体");
}
}
// 3. 逐字符渲染 + 旋转 + 合成
std::uniform_int_distribution<int> angle_dist(-30, 30);
std::uniform_int_distribution<int> size_dist(20, 38);
std::uniform_int_distribution<int> y_dist(2, 12);
std::uniform_int_distribution<int> x_jitter(-3, 3);
for (size_t i = 0; i < text.size(); ++i) {
int char_size = size_dist(t_rng);
FT_Set_Pixel_Sizes(face, 0, char_size);
if (FT_Load_Char(face, text[i], FT_LOAD_RENDER) != 0)
continue;
auto& bmp = face->glyph->bitmap;
if (bmp.buffer == nullptr || bmp.width == 0 || bmp.rows == 0)
continue;
auto fg = random_color();
int angle = angle_dist(t_rng);
int y = y_dist(t_rng);
int x = 15 + static_cast<int>(i) * 32 + x_jitter(t_rng);
int rw, rh;
auto rotated = rotate_bitmap(bmp, angle, rw, rh);
// 合成位置:目标中心对准期望位置
int cx = x - rw / 2;
int cy = y - rh / 2;
composite_char(pixels, rotated, rw, rh, cx, cy, fg);
}
FT_Done_Face(face);
FT_Done_FreeType(ft);
// 4. 4 条随机干扰线
std::uniform_int_distribution<int> xd(0, kImgWidth - 1);
std::uniform_int_distribution<int> yd(0, kImgHeight - 1);
for (int i = 0; i < 4; ++i) {
draw_line(pixels, xd(t_rng), yd(t_rng), xd(t_rng), yd(t_rng), random_color());
}
// 5. 50 个随机噪点
for (int i = 0; i < 50; ++i) {
set_pixel(pixels, xd(t_rng), yd(t_rng), random_color());
}
// 6. PNG 编码
std::vector<uint8_t> png_buf;
stbi_write_png_to_func(
[](void* ctx, void* data, int size) {
auto* buf = static_cast<std::vector<uint8_t>*>(ctx);
buf->insert(buf->end(), static_cast<uint8_t*>(data), static_cast<uint8_t*>(data) + size);
},
&png_buf, kImgWidth, kImgHeight, kImgChannels, pixels.data(), kImgWidth * kImgChannels);
return png_buf;
}
// ═══════════════════════════════════════════════════════════════════════════
// 6. 滑动窗口速率限制
// ═══════════════════════════════════════════════════════════════════════════
class RateLimiter {
public:
bool allow(int max_per_minute) {
if (max_per_minute <= 0) return true; // 0 = 不限制
std::lock_guard<std::mutex> lock(mtx_);
auto now = std::chrono::steady_clock::now();
while (!timestamps_.empty() &&
std::chrono::duration_cast<std::chrono::seconds>(
now - timestamps_.front()).count() > 60) {
timestamps_.pop_front();
}
if (static_cast<int>(timestamps_.size()) >= max_per_minute)
return false;
timestamps_.push_back(now);
return true;
}
private:
std::deque<std::chrono::steady_clock::time_point> timestamps_;
std::mutex mtx_;
};
// ═══════════════════════════════════════════════════════════════════════════
// 7. main() — RpcServer 启动
// ═══════════════════════════════════════════════════════════════════════════
static CaptchaConfig g_config;
static CaptchaStore g_store;
static RateLimiter g_rate_limiter;
static auto& g_log = LogC::instance();
int main(int argc, char* argv[]) {
try {
// ── 命令行参数 ──
std::string confs_host = "127.0.0.1";
int confs_port = 8010;
{
const char* env;
if ((env = std::getenv("CONFS_HOST"))) confs_host = env;
if ((env = std::getenv("CONFS_PORT"))) confs_port = std::stoi(env);
}
po::options_description desc("captcha2 — pymsg 验证码服务");
desc.add_options()
("help,h", "显示帮助")
("addr,a", po::value<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::cerr << "[captcha2] 配置来源: confs captcha.ini @ "
<< confs_host << ":" << confs_port << std::endl;
std::cerr << "[captcha2] captcha_len=" << g_config.len
<< " ttl=" << g_config.ttl
<< " max_store=" << g_config.max_store
<< " rate_limit=" << g_config.rate_limit_per_min << "/min" << std::endl;
// 校验字体文件存在
{
FILE* ftest = std::fopen(g_config.font_path.c_str(), "rb");
if (!ftest) {
std::cerr << "[captcha2] 致命错误: 字体文件不存在 " << g_config.font_path << std::endl;
return 1;
}
std::fclose(ftest);
}
// ── 启动 RpcServer ──
g_log.info("captcha2", "启动中... 监听 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});
// 路由: image
rpc.add_route("image", [](const json::object&) -> json::value {
// 速率限制(P3-5: 抛异常由 RpcServer 包装为 success:false,gateway 映射 429)
if (!g_rate_limiter.allow(g_config.rate_limit_per_min)) {
g_log.warning("captcha2", "速率限制触发,拒绝生成验证码");
throw std::runtime_error("rate_limit_exceeded");
}
std::string text = generate_text(g_config.len);
// 小写化存储,与 Python 版行为对齐(大小写不敏感校验)
std::string answer_lc = text;
std::transform(answer_lc.begin(), answer_lc.end(), answer_lc.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
// P3-5: generate 异常直接传播(RpcServer 自动包装为 success:false,gateway 映射 503)
std::string token = g_store.generate(answer_lc, g_config.ttl, g_config.max_store);
auto png_bytes = generate_image(text, g_config.font_path);
auto png_b64 = base64_encode(png_bytes);
g_log.info("captcha2", "验证码已生成 token=" + token.substr(0, 8) + "...");
json::object data;
data["token"] = token;
data["image"] = png_b64;
return data;
});
// 路由: verify
rpc.add_route("verify", [](const json::object& req) -> json::value {
std::string token, answer;
auto it_tok = req.find("token");
auto it_ans = req.find("answer");
if (it_tok == req.end() || it_ans == req.end() ||
!it_tok->value().is_string() || !it_ans->value().is_string()) {
json::object data;
data["valid"] = false;
data["error"] = "token and answer required";
return data;
}
token = json::value_to<std::string>(it_tok->value());
answer = json::value_to<std::string>(it_ans->value());
// 小写化(使用 unsigned char 避免 UB)
std::transform(answer.begin(), answer.end(), answer.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto result = g_store.verify(token, answer, g_config.ttl);
if (!result.token_found) {
g_log.warning("captcha2", "验证码校验失败-无效Token token=" + token.substr(0, std::min<size_t>(8, token.size())) + "...");
json::object data;
data["valid"] = false;
data["error"] = "invalid or expired token";
return data;
}
if (!result.valid) {
g_log.warning("captcha2", "验证码校验失败-答案错误 token=" + token.substr(0, std::min<size_t>(8, token.size())) + "...");
}
json::object data;
data["valid"] = result.valid;
return data;
});
// 路由: health
rpc.add_route("health", [](const json::object&) -> json::value {
json::object data;
data["status"] = "ok";
data["service"] = "captcha";
return data;
});
std::cerr << "[captcha2] 监听 tcp://" << g_config.host << ":" << g_config.port << std::endl;
g_log.info("captcha2", "开始监听 tcp://" + g_config.host + ":" + std::to_string(g_config.port));
rpc.start(); // 阻塞
} catch (const std::exception& e) {
std::cerr << "[captcha2] 致命错误: " << e.what() << std::endl;
g_log.error("captcha2", std::string("致命错误: ") + e.what());
return 1;
}
return 0;
}
+219
View File
@@ -0,0 +1,219 @@
/*
* test_gen.cpp — 快速验证图片生成(不依赖 confs/RpcServer)
* 用法: make -f Makefile.test && ./test_gen.bin
*/
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "inc/stb_image_write.h"
#include <ft2build.h>
#include FT_FREETYPE_H
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <mutex>
#include <random>
#include <sstream>
#include <vector>
// ── 复制 main.cpp 中的核心代码(简化为测试版)───────────────────────
static constexpr int kImgWidth = 160;
static constexpr int kImgHeight = 50;
static constexpr int kImgChannels = 3;
struct RGB { uint8_t r, g, b; };
static thread_local std::mt19937 t_rng(std::random_device{}());
static RGB random_color() {
std::uniform_int_distribution<int> d(0, 255);
return {static_cast<uint8_t>(d(t_rng)), static_cast<uint8_t>(d(t_rng)), static_cast<uint8_t>(d(t_rng))};
}
static std::string generate_text(int len) {
static const char kChars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
static constexpr size_t kNumChars = sizeof(kChars) - 1;
thread_local std::mt19937 rng(std::random_device{}());
std::uniform_int_distribution<size_t> dist(0, kNumChars - 1);
std::string text;
for (int i = 0; i < len; ++i) text.push_back(kChars[dist(rng)]);
return text;
}
static inline uint8_t* pixel_at(std::vector<uint8_t>& pix, int x, int y) {
return &pix[(y * kImgWidth + x) * kImgChannels];
}
static std::vector<uint8_t> rotate_bitmap(
const FT_Bitmap& src, double angle_deg,
int& out_w, int& out_h, int& offset_x, int& offset_y)
{
double rad = angle_deg * M_PI / 180.0;
double cos_a = std::cos(rad), sin_a = std::sin(rad);
int sw = static_cast<int>(src.width), sh = static_cast<int>(src.rows);
double corners[4][2] = {
{0.0, 0.0}, {double(sw), 0.0}, {double(sw), double(sh)}, {0.0, double(sh)}
};
double min_x = 1e9, max_x = -1e9, min_y = 1e9, max_y = -1e9;
for (auto& c : corners) {
double rx = c[0] * cos_a - c[1] * sin_a;
double ry = c[0] * sin_a + c[1] * cos_a;
if (rx < min_x) min_x = rx;
if (rx > max_x) max_x = rx;
if (ry < min_y) min_y = ry;
if (ry > max_y) max_y = ry;
}
out_w = static_cast<int>(std::ceil(max_x - min_x));
out_h = static_cast<int>(std::ceil(max_y - min_y));
offset_x = static_cast<int>(std::floor(min_x));
offset_y = static_cast<int>(std::floor(min_y));
std::vector<uint8_t> out(out_w * out_h, 0);
for (int dy = 0; dy < out_h; ++dy) {
for (int dx = 0; dx < out_w; ++dx) {
double sx = (dx + min_x) * cos_a + (dy + min_y) * sin_a;
double sy = -(dx + min_x) * sin_a + (dy + min_y) * cos_a;
int six = static_cast<int>(std::floor(sx));
int siy = static_cast<int>(std::floor(sy));
if (six >= 0 && six < sw && siy >= 0 && siy < sh)
out[dy * out_w + dx] = src.buffer[siy * src.pitch + six];
}
}
return out;
}
static void composite_char(
std::vector<uint8_t>& pixels,
const std::vector<uint8_t>& glyph, int gw, int gh,
int dst_x, int dst_y, const RGB& color)
{
for (int gy = 0; gy < gh; ++gy) {
for (int gx = 0; gx < gw; ++gx) {
uint8_t alpha = glyph[gy * gw + gx];
if (alpha == 0) continue;
int px = dst_x + gx, py = dst_y + gy;
if (px < 0 || px >= kImgWidth || py < 0 || py >= kImgHeight) continue;
auto* p = pixel_at(pixels, px, py);
float a = alpha / 255.0f;
p[0] = static_cast<uint8_t>(p[0] * (1 - a) + color.r * a);
p[1] = static_cast<uint8_t>(p[1] * (1 - a) + color.g * a);
p[2] = static_cast<uint8_t>(p[2] * (1 - a) + color.b * a);
}
}
}
static void draw_line(std::vector<uint8_t>& pixels, int x0, int y0, int x1, int y1, const RGB& color) {
int dx = std::abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -std::abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy;
while (true) {
if (x0 >= 0 && x0 < kImgWidth && y0 >= 0 && y0 < kImgHeight) {
auto* p = pixel_at(pixels, x0, y0);
p[0] = color.r; p[1] = color.g; p[2] = color.b;
}
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
static void set_pixel(std::vector<uint8_t>& pixels, int x, int y, const RGB& color) {
if (x >= 0 && x < kImgWidth && y >= 0 && y < kImgHeight) {
auto* p = pixel_at(pixels, x, y);
p[0] = color.r; p[1] = color.g; p[2] = color.b;
}
}
static std::vector<uint8_t> generate_image(const std::string& text, const std::string& font_path) {
std::vector<uint8_t> pixels(kImgWidth * kImgHeight * kImgChannels);
auto bg = random_color();
for (int i = 0; i < kImgWidth * kImgHeight; ++i) {
pixels[i * 3 + 0] = bg.r;
pixels[i * 3 + 1] = bg.g;
pixels[i * 3 + 2] = bg.b;
}
FT_Library ft;
if (FT_Init_FreeType(&ft) != 0) throw std::runtime_error("FreeType init failed");
FT_Face face;
if (FT_New_Face(ft, font_path.c_str(), 0, &face) != 0) {
FT_Done_FreeType(ft);
throw std::runtime_error("Font not found: " + font_path);
}
std::uniform_int_distribution<int> angle_dist(-30, 30);
std::uniform_int_distribution<int> size_dist(20, 38);
std::uniform_int_distribution<int> y_dist(2, 12);
std::uniform_int_distribution<int> x_jitter(-3, 3);
for (size_t i = 0; i < text.size(); ++i) {
int char_size = size_dist(t_rng);
FT_Set_Pixel_Sizes(face, 0, char_size);
if (FT_Load_Char(face, text[i], FT_LOAD_RENDER) != 0) continue;
auto& bmp = face->glyph->bitmap;
if (bmp.buffer == nullptr || bmp.width == 0 || bmp.rows == 0) continue;
auto fg = random_color();
int angle = angle_dist(t_rng);
int y = y_dist(t_rng);
int x = 15 + static_cast<int>(i) * 32 + x_jitter(t_rng);
int rw, rh, ox, oy;
auto rotated = rotate_bitmap(bmp, angle, rw, rh, ox, oy);
int cx = x - rw / 2, cy = y - rh / 2;
composite_char(pixels, rotated, rw, rh, cx, cy, fg);
}
FT_Done_Face(face);
FT_Done_FreeType(ft);
std::uniform_int_distribution<int> xd(0, kImgWidth - 1);
std::uniform_int_distribution<int> yd(0, kImgHeight - 1);
for (int i = 0; i < 4; ++i)
draw_line(pixels, xd(t_rng), yd(t_rng), xd(t_rng), yd(t_rng), random_color());
for (int i = 0; i < 50; ++i)
set_pixel(pixels, xd(t_rng), yd(t_rng), random_color());
std::vector<uint8_t> png_buf;
stbi_write_png_to_func(
[](void* ctx, void* data, int size) {
auto* buf = static_cast<std::vector<uint8_t>*>(ctx);
buf->insert(buf->end(), static_cast<uint8_t*>(data), static_cast<uint8_t*>(data) + size);
}, &png_buf, kImgWidth, kImgHeight, kImgChannels, pixels.data(), kImgWidth * kImgChannels);
return png_buf;
}
// ── main — 生成测试 PNG ──────────────────────────────────────────────────
int main(int argc, char* argv[]) {
std::string font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
std::string text = "AB3X";
if (argc >= 2) text = argv[1];
if (argc >= 3) font_path = argv[2];
std::cout << "文字: " << text << std::endl;
std::cout << "字体: " << font_path << std::endl;
try {
auto png = generate_image(text, font_path);
std::string fname = "test_output_" + text + ".png";
std::ofstream out(fname, std::ios::binary);
out.write(reinterpret_cast<const char*>(png.data()), png.size());
out.close();
std::cout << "✓ 已生成: " << fname << " (" << png.size() << " bytes)" << std::endl;
} catch (const std::exception& e) {
std::cerr << "✗ 错误: " << e.what() << std::endl;
return 1;
}
return 0;
}
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
test_rpc.py — captcha2 pymsg RPC 功能验证脚本
用法: python3 test_rpc.py [host] [port]
默认: python3 test_rpc.py 127.0.0.1 8009
"""
import base64
import os
import sys
import time
_here = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_here, "..", "pymsg"))
from pymsg import RpcClient # noqa: E402
HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8009
passed = 0
failed = 0
def test(name: str, expect_ok: bool, fn):
global passed, failed
try:
result = fn()
# RPC 调用成功 = 返回 dict 且没有 error 字段(或 error 是预期的一部分)
rpc_ok = isinstance(result, dict)
if expect_ok == rpc_ok:
print(f" ✓ {name} → {result}")
passed += 1
return result
else:
print(f" ✗ {name} — 预期 {'成功' if expect_ok else '失败'},实际: {result}")
failed += 1
return result
except Exception as e:
if not expect_ok:
print(f" ✓ {name} (预期异常: {e})")
passed += 1
else:
print(f" ✗ {name} — 异常: {e}")
failed += 1
return None
def main():
global passed, failed
print(f"captcha2 RPC 功能测试 — tcp://{HOST}:{PORT}")
print("=" * 60)
client = RpcClient(HOST, PORT, "test-captcha2", timeout=5.0)
# ── 1. health ──
print("\n[1] health 检查")
r = test("health 返回 ok", True, lambda: client.call("health"))
if r:
print(f" 服务: {r.get('service')}, 状态: {r.get('status')}")
# ── 2. image 生成 ──
print("\n[2] image 验证码生成")
r = test("image 返回 token+image", True, lambda: client.call("image"))
token = ""
if r:
token = r.get("token", "")
img_b64 = r.get("image", "")
print(f" token: {token[:16]}... (len={len(token)})")
print(f" image base64: {len(img_b64)} 字符")
# 解码保存 PNG
try:
img_bytes = base64.b64decode(img_b64)
fname = f"/tmp/captcha2_test_{token[:8]}.png"
with open(fname, "wb") as f:
f.write(img_bytes)
print(f" PNG 已保存: {fname} ({len(img_bytes)} bytes)")
except Exception as e:
print(f" ⚠ PNG 保存失败: {e}")
# ── 3. verify 校验 ──
print("\n[3] verify 校验")
# 3a. 正向校验(仅在终端交互模式下)
if sys.stdin.isatty():
print(" 生成验证码用于正向校验...")
r2 = client.call("image")
token2 = r2.get("token", "")
if token2 and r2.get("image"):
img_bytes = base64.b64decode(r2["image"])
fname = f"/tmp/captcha2_manual_{token2[:8]}.png"
with open(fname, "wb") as f:
f.write(img_bytes)
print(f" 请查看图片: {fname}")
print(f" 输入图片中的 4 位字符(不区分大小写): ", end="", flush=True)
try:
user_answer = sys.stdin.readline().strip()
except (EOFError, KeyboardInterrupt):
user_answer = ""
if user_answer:
vr = client.call("verify", {"token": token2, "answer": user_answer})
is_valid = bool(vr.get("valid", False))
if is_valid:
print(f" ✓ verify 正向校验 → {{'valid': True}}(答案 '{user_answer}' 正确)")
passed += 1
else:
print(f" ✗ verify 正向校验 → {{'valid': False}}(答案 '{user_answer}' 未通过)")
failed += 1
else:
print(f" ⚠ 跳过(无输入)")
else:
print(" ⚠ 非交互模式,跳过正向校验(需人工读取 PNG)")
# 3b. 错误答案 → RPC 成功返回 valid=false
test("verify 错误答案 → valid=false", True,
lambda: client.call("verify", {"token": token, "answer": "ZZZZ"}))
# 3c. 大小写不敏感(P0 修复验证)
r3 = client.call("image")
token3 = r3.get("token", "")
if token3:
# 输入全大写,验证大小写不敏感(P0 修复后应失效——答案存的是小写)
# 这里仅验证 RPC 调用不崩溃
test("verify 全大写答案 → RPC 成功", True,
lambda: client.call("verify", {"token": token3, "answer": "ABCD"}))
# 3d. 缺参数 → RPC 成功返回 valid=false + error
test("verify 缺参数 → valid=false", True,
lambda: client.call("verify", {}))
# 3e. 无效 token → RPC 成功返回 valid=false + error
test("verify 无效 token → valid=false", True,
lambda: client.call("verify", {"token": "deadbeef" * 8, "answer": "abcd"}))
# ── 4. 速率限制压力测试(P1-1: 全局限流 600/min,70 次应全部通过;gateway 层按 IP 限流 10/min) ──
print("\n[4] captcha2 自身全局限流(阈值 600/min,70 次请求)")
ok_count = 0
rate_limited = 0
for i in range(70):
try:
r = client.call("image")
if r.get("token"):
ok_count += 1
elif not r.get("success", True):
rate_limited += 1
except Exception:
rate_limited += 1
print(f" 成功: {ok_count}, 被限: {rate_limited}")
# P1-1 修复后,全局限流阈值调到 600,70 次应全部通过
if ok_count == 70:
print(f" ✓ 全局限流未误拦(70/70 通过,阈值 600/min 正确)")
passed += 1
else:
print(f" ✗ 预期 70 次全通过,实际成功 {ok_count}")
failed += 1
# ── 结果汇总 ──
print("\n" + "=" * 60)
print(f"结果: {passed} 通过, {failed} 失败")
return failed == 0
if __name__ == "__main__":
ok = main()
sys.exit(0 if ok else 1)