diff --git a/_inst.sh b/_inst.sh index 0b6a280..07c006a 100755 --- a/_inst.sh +++ b/_inst.sh @@ -40,7 +40,6 @@ inst_module "db" inst_module "db2" inst_module "smtp" inst_module "smtp3" -inst_module "captcha" inst_module "captcha2" inst_module "confs" inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像) diff --git a/captcha/.gitignore b/captcha/.gitignore deleted file mode 100644 index ef8f28f..0000000 --- a/captcha/.gitignore +++ /dev/null @@ -1,111 +0,0 @@ -#prjs -*.bin -*.build -*.dist -*.onefile-build -nuitka-crash-report.xml - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ diff --git a/captcha/README.md b/captcha/README.md deleted file mode 100644 index 77c625a..0000000 --- a/captcha/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# CAPTCHA 验证码服务 - -> **已废弃**:生产已由 [captcha2](../captcha2/)(C++20 重写)平替,不再部署;本 README 为历史说明。 - -一个轻量级的图片验证码服务,基于自研 [pymsg](../pymsg/) 协议(TCP 长度前缀帧 + JSON),配合 Pillow 生成验证码图片。提供图片生成、Token 校验、健康检查三个路由,由 `gateway` 经 pymsg 调用并代理给浏览器。 - -服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8009`(TCP),仅允许本机/内网调用。 - -## 功能特性 - -- **图片验证码生成**:4 位字符(字母 + 数字),自动排除易混淆字符(`l I 1 O 0 o`) -- **Token 机制**:每次生成图片返回唯一 Token(32 位十六进制),校验时**一次性消费**,防止重放 -- **过期自动清理**:Token 默认 300 秒(5 分钟)失效;`image` 路由请求时顺带清理过期项 -- **线程安全**:使用锁(`STORE_LOCK`)保护内存存储,支持并发请求 -- **恒定时间比较**:答案校验使用逐字符异或(`diff |= ord(a) ^ ord(b)`),避免计时攻击 -- **干扰元素**:随机字符颜色、垂直位置偏移、干扰线与噪点,提升机器识别难度 -- **零框架依赖**:基于 pymsg(项目内置库),除 Pillow 外无第三方依赖 - -## 技术栈 - -- Python 3 -- [Pillow](https://python-pillow.org/)(PIL)—— 图片绘制 -- [pymsg](../pymsg/) —— TCP 长度前缀帧协议(`[4字节大端长度][UTF-8 JSON]`) - -## 安装 - -```bash -# 自动创建 venv 并安装依赖(推荐) -bash _set.sh - -# 或手动安装 -pip install Pillow -``` - -> 字体默认使用 `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`(Linux 常见路径)。若系统中不存在该字体,会自动回退到 PIL 默认字体。 - -## 运行 - -```bash -# 方式一:通过启动脚本(推荐,由 core 守护时即使用此方式) -cd captcha && ./_run.sh - -# 方式二:直接运行 -python3 captcha_service.py # 默认 127.0.0.1:8009 -python3 captcha_service.py --port 8009 --addr 127.0.0.1 -``` - -## 协议与路由 - -服务基于 pymsg 协议:每条消息为完整 JSON,`_route` 字段指定路由。响应格式与 db 服务一致: - -```json -{"success": true, "data": {...}} -{"success": false, "error": "..."} -``` - -| _route | 说明 | -| ------- | ------------------------------------------ | -| `image` | 生成验证码图片(base64 PNG + Token) | -| `verify` | 校验用户输入,一次性消费 Token | -| `health` | 健康检查 | - -### 1. image — 生成验证码图片 - -**请求**: - -```json -{"_route": "image"} -``` - -**响应**: - -```json -{ - "success": true, - "data": { - "token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c", - "image": "" - } -} -``` - -> Token 有效期 300 秒,且**一次性使用**——无论校验成功与否,第一次 `verify` 调用后即被消费。 - -### 2. verify — 校验验证码 - -**请求**: - -```json -{"_route": "verify", "token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c", "answer": "AB3XY"} -``` - -**响应**(均为 `success: true`,业务结果在 `data` 中区分): - -| 响应 data | 触发条件 | -| ------------------------------------------------------------- | ------------------------------ | -| `{ "valid": true }` | Token 有效且答案正确 | -| `{ "valid": false }` | Token 有效但**答案错误**(Token 已消费) | -| `{ "valid": false, "error": "token and answer required" }` | 缺少 `token` 或 `answer` 字段 | -| `{ "valid": false, "error": "invalid or expired token" }` | Token 无效、已消费或已过期 | - -> ⚠️ **注意区分**:答案错误与 Token 错误是两种不同的结果。消费方(如 gateway)应分别处理:前者提示"验证码错误",后者提示"请重新获取验证码"。 - -### 3. health — 健康检查 - -**请求**:`{"_route": "health"}` -**响应**:`{"success": true, "data": {"status": "ok", "service": "captcha"}}` - -### 调用示例(Python) - -```python -import json, sys -sys.path.insert(0, "../pymsg") -from pymsg import MsgClient - -c = MsgClient("127.0.0.1", 8009, "my-service") -resp = json.loads(c.call(json.dumps({"_route": "image"}), timeout=5)) -token = resp["data"]["token"] -png_b64 = resp["data"]["image"] -``` - -## 数据模型与 Token 生命周期 - -- **存储**:进程内存字典 `captcha_store`,键为 Token,值为 `{ answer: <小写答案>, created_at: }`。 -- **生成**:`image` 路由时写入一条记录,并返回对应 Token 与 base64 PNG。 -- **消费**:`verify` 路由时 `pop` 移除该 Token,无论答案对错,均不可再次使用。 -- **过期**:超过 `CAPTCHA_TTL`(300 秒)即视为失效。`image` 路由每次生成时顺带清理过期项;`verify` 时若 Token 不在存储中(过期、未生成或已消费),统一返回 `invalid or expired token`。 - -## 集成关系 - -- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/captcha/_run.sh` 拉起并守护。 -- `gateway` 提供 **`GET /captcha/image`** 代理路由:经 pymsg 调本服务的 `image` 路由,将 base64 PNG 解码后以 `image/png` 响应体 + `X-Captcha-Token` 响应头返回给浏览器(`Cache-Control: no-store`)。 -- `gateway` 在 `/login` 与 `/register` 中可选地启用 CAPTCHA 校验(以 `captcha_token` + `captcha_answer` 字段传入,见 `gateway/README.md`)。 - -## 典型使用流程 - -1. 前端请求 `GET https:///captcha/image`(经 waf → gateway → captcha),展示图片并保存 `X-Captcha-Token`。 -2. 用户根据图片内容输入验证码文本。 -3. 前端在登录/注册请求中携带 `captcha_token` + `captcha_answer`。 -4. 服务端比对答案(小写化、恒定时间比较),Token 随即作废(无论对错)。 - -## 项目结构 - -``` -captcha/ -├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验) -├── _set.sh # 初始化脚本(创建 venv + 安装依赖) -├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py) -├── requirements.txt # Python 依赖(pymsg、Pillow) -└── README.md -``` - -## 注意事项 - -- 验证码存储在进程内存中,服务重启后所有 Token 失效;**不支持多实例水平扩展**(如需分布式部署,应将 `captcha_store` 替换为 Redis 等外部存储,并相应调整过期清理逻辑)。 -- 默认仅监听 `127.0.0.1`,仅供本机或内网调用;浏览器侧图片获取统一走 `gateway` 的 `GET /captcha/image` 代理。 -- 答案校验不区分大小写;Token 一经校验即从内存中移除(一次性使用)。 -- 字体缺失时自动回退到 PIL 默认字体,可能影响验证码观感,部署前建议确认 DejaVu 字体已安装。 -- 旧版 HTTP 接口(http.server)已下线,协议与 db 服务统一为 pymsg/TCP。 diff --git a/captcha/_run.sh b/captcha/_run.sh deleted file mode 100755 index 2cac9a1..0000000 --- a/captcha/_run.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -cd "$(dirname "$0")" || exit -source venv/bin/activate -exec python3 captcha_service.py diff --git a/captcha/_set.sh b/captcha/_set.sh deleted file mode 100755 index e0f738f..0000000 --- a/captcha/_set.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -cd "$(dirname "$0")" || exit -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple -#nuitka --standalone smtp.py diff --git a/captcha/captcha_service.py b/captcha/captcha_service.py deleted file mode 100644 index b00c05e..0000000 --- a/captcha/captcha_service.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。 - -替代原 http.server HTTP 接口,通过 TCP 长度前缀帧收发 JSON。 -协议:每条消息为完整 JSON,``_route`` 字段对应路由名。 - -请求示例:: - - {"_route": "image"} → 生成验证码图片 - {"_route": "verify", "token": "...", "answer": "AB3XY"} → 校验 - {"_route": "health"} → 健康检查 - -响应格式(与 dbv2 一致):: - - {"success": true, "data": {...}} - {"success": false, "error": "..."} - -说明:PNG 图片为二进制,经 base64 编码后放入 JSON 返回; -调用方(gateway)解码后以 image/png 代理给浏览器。 -""" - -import base64 -import configparser -import io -import os -import random -import secrets -import string -import threading -import time - -from PIL import Image, ImageDraw, ImageFont - -# pymsg / log_client 导入 -import sys as _sys -_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import RpcServer # noqa: E402 -from log_client import logs # noqa: E402 - -# ── 配置加载(两层:confs 强依赖 → 环境变量覆盖)──────────────────────── -# confs 是 captcha 启动的强依赖:必须成功从 confs 拉取 captcha.ini,否则直接退出。 -# 环境变量优先级最高(Docker 兼容),可覆盖 confs 中的配置值。 -# confs 自身的连接地址通过引导配置确定:环境变量 CONFS_HOST/CONFS_PORT -# 优先,缺省使用 127.0.0.1:8010(与其他服务一致的约定)。 - -# 引导配置:confs 连接地址(不依赖 confs 本身) -_confs_host = os.environ.get('CONFS_HOST', '127.0.0.1') -_confs_port = int(os.environ.get('CONFS_PORT', '8010')) - -# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段 -_captcha_config = { - 'server': {'host': '', 'port': '', 'workers': ''}, - 'captcha': {'len': '', 'ttl': '', 'font_path': '', 'max_store': ''}, -} - - -def _merge_ini(text): - """把 ini 文本合并进 _captcha_config(仅覆盖已知的 section/key)。""" - ini = configparser.ConfigParser(interpolation=None) - ini.read_string(text) - for section in _captcha_config: - if ini.has_section(section): - for key, value in ini[section].items(): - if key in _captcha_config[section]: - _captcha_config[section][key] = value - - -def _fetch_from_confs(): - """从 confs 统一配置服务拉取 captcha.ini 全文。 - - 使用 retry_on_refused=True:confs 未就绪时 pymsg 自动阻塞重试(每秒一次, - 每 5 秒打印一次等待提示),直到 confs 启动后自动连接成功。 - RpcError(如配置文件不存在)仍直接报错退出——这是配置错误而非启动时序问题。 - """ - from pymsg import RpcClient, RpcError # noqa: E402 - client = RpcClient(_confs_host, _confs_port, 'captcha', timeout=5, - retry_on_refused=True) - try: - data = client.call('get', {'name': 'captcha.ini'}) - except RpcError as e: - print(f'[captcha] 致命错误: 从 confs 获取 captcha.ini 失败: {e}', - file=_sys.stderr) - _sys.exit(1) - return data.get('content') - - -_confs_text = _fetch_from_confs() -_merge_ini(_confs_text) -_config_source = f'confs (captcha.ini @ {_confs_host}:{_confs_port})' -print(f'[captcha] 配置来源: {_config_source}') - -# 环境变量覆盖(最高优先级,保持 Docker 兼容) -if os.environ.get('LISTEN_ADDR'): - _captcha_config['server']['host'] = os.environ['LISTEN_ADDR'] -if os.environ.get('LISTEN_PORT'): - _captcha_config['server']['port'] = os.environ['LISTEN_PORT'] -if os.environ.get('WORKERS'): - _captcha_config['server']['workers'] = os.environ['WORKERS'] -if os.environ.get('CAPTCHA_FONT_PATH'): - _captcha_config['captcha']['font_path'] = os.environ['CAPTCHA_FONT_PATH'] -if os.environ.get('CAPTCHA_MAX_STORE'): - _captcha_config['captcha']['max_store'] = os.environ['CAPTCHA_MAX_STORE'] - -# 配置完整性校验 -_required = { - 'server': ['host', 'port', 'workers'], - 'captcha': ['len', 'ttl'], -} -# max_store 是可选配置(非必填) -for section, keys in _required.items(): - for key in keys: - if not _captcha_config[section][key]: - print(f'[captcha] 致命错误: 配置项 [{section}] {key} 缺失(captcha.ini 中未配置且无环境变量覆盖)', - file=_sys.stderr) - _sys.exit(1) - -# 解析为最终常量 -CAPTCHA_LEN = int(_captcha_config['captcha']['len']) -CAPTCHA_TTL = int(_captcha_config['captcha']['ttl']) - -# 验证码存储容量上限(可选配置,默认 10000 个),防止内存耗尽(审计 P1-1) -_default_max_store = '10000' -MAX_STORE_SIZE = int(_captcha_config['captcha']['max_store'] or _default_max_store) -if MAX_STORE_SIZE <= 0: - print('[captcha] 致命错误: 配置项 [captcha] max_store 必须为正整数', - file=_sys.stderr) - _sys.exit(1) - -# 字体路径:可选配置项(缺失或用空值时不致命,回退到代码默认值; -# 即使默认值文件不存在,PIL 在 truetype() 失败时会回退到 load_ Default())。 -DEFAULT_FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' -FONT_PATH = _captcha_config['captcha']['font_path'] or DEFAULT_FONT_PATH - -logs.info("captcha", f"配置来源: {_config_source}") - -CHARS = string.ascii_letters + string.digits -CHARS = CHARS.translate(str.maketrans('', '', 'lI1O0o')) # 剔除易混淆字符 - -captcha_store = {} # token -> {'answer': str, 'created_at': float} -STORE_LOCK = threading.Lock() - - -# ── 验证码生成 ────────────────────────────────────────────────────────── - -def generate_text(): - return ''.join(random.choices(CHARS, k=CAPTCHA_LEN)) - - -def generate_image(text): - width, height = 160, 50 - bg = (random.randint(0,256), random.randint(0,256), random.randint(0,256)) - img = Image.new('RGB', (width, height), bg) - draw = ImageDraw.Draw(img) - - for i, ch in enumerate(text): - color = (random.randint(0,256), random.randint(0,256), random.randint(0,256)) - y = random.randint(2, 12) - x = 15 + i * 32 + random.randint(-3, 3) # 4 位字符在 160px 宽度内均匀分布 - try: - font = ImageFont.truetype(FONT_PATH, - random.choice(range(20,40,2))) - except (IOError, OSError): - font = ImageFont.load_default() - - char = Image.new("RGBA", (64, 64), (0,0,0,0)) - draw_char = ImageDraw.Draw(char) - draw_char.text((20,20), ch, fill=color, font=font) - rotated_char = char.rotate(random.randint(-30, 30), expand=True) - img.paste(rotated_char, (x-32, y-32), mask=rotated_char) - # draw.text((x, y), ch, fill=color, font=font) - - for _ in range(4): - draw.line([(random.randint(0, width), random.randint(0, height)), - (random.randint(0, width), random.randint(0, height))], - fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256))) - for _ in range(50): - draw.point((random.randint(0, width - 1), random.randint(0, height - 1)), - fill=(random.randint(0,256), random.randint(0,256), random.randint(0,256))) - - buf = io.BytesIO() - img.save(buf, format='PNG') - return buf.getvalue() - - -# ═══════════════════════════════════════════════════════════════════════ -# CaptchaServer — 主服务类 -# ═══════════════════════════════════════════════════════════════════════ - -class CaptchaServer: - """基于 pymsg 的验证码服务,通过 TCP 长连接收发 JSON。""" - - def __init__(self, addr="127.0.0.1", port=8009, workers=4): - self.addr = addr - self.port = port - self._server = RpcServer(addr, port, workers=workers) - self._server.add_route("image", self._handle_image) - self._server.add_route("verify", self._handle_verify) - self._server.add_route("health", self._handle_health) - - # ── 启动 / 停止 ────────────────────────────────────────────────── - def start(self) -> None: - """启动服务(阻塞当前线程)。""" - print(f"[captcha] 监听 tcp://{self.addr}:{self.port}") - logs.info("captcha", f"开始监听 tcp://{self.addr}:{self.port}") - self._server.start() - - def stop(self) -> None: - """从另一线程停止服务。""" - self._server.stop() - - # ═══════════════════════════════════════════════════════════════════ - # 路由处理 - # ═══════════════════════════════════════════════════════════════════ - - def _handle_image(self, _req): - """生成验证码图片,返回 token 与 base64 编码的 PNG。""" - text = generate_text() - token = secrets.token_hex(16) - - with STORE_LOCK: - # 惰性清理过期项 - now = time.time() - expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL] - for k in expired: - del captcha_store[k] - - # 容量上限保护(P1-1):清理过期后仍超限则拒绝新生成,防止内存耗尽 - if len(captcha_store) >= MAX_STORE_SIZE: - logs.warning("captcha", - f"验证码存储已满({len(captcha_store)}/{MAX_STORE_SIZE}),拒绝生成") - return {"success": False, - "error": "too many pending captchas, please try again later"} - - captcha_store[token] = {'answer': text.lower(), 'created_at': now} - - png_bytes = generate_image(text) - logs.info("captcha", f"验证码已生成 token={token[:8]}...") - return { - "token": token, - "image": base64.b64encode(png_bytes).decode('ascii'), - } - - def _handle_verify(self, req): - """校验答案,Token 一次性消费;恒定时间比较防计时攻击。""" - token = str(req.get("token", "")).strip() - answer = str(req.get("answer", "")).strip().lower() - if not token or not answer: - return {"valid": False, "error": "token and answer required"} - - with STORE_LOCK: - entry = captcha_store.pop(token, None) - - if not entry: - logs.warning("captcha", f"验证码校验失败-无效Token token={token[:8]}...") - return {"valid": False, "error": "invalid or expired token"} - - expected = entry['answer'] - if len(answer) != len(expected): - valid = False - else: - diff = 0 - for a, b in zip(answer, expected): - diff |= ord(a) ^ ord(b) - valid = diff == 0 - - if not valid: - logs.warning("captcha", f"验证码校验失败-答案错误 token={token[:8]}...") - - return {"valid": valid} - - def _handle_health(self, _req): - return {"status": "ok", "service": "captcha"} - - -# ═══════════════════════════════════════════════════════════════════════ -# 入口 -# ═══════════════════════════════════════════════════════════════════════ - -if __name__ == '__main__': - import argparse - - default_addr = _captcha_config['server']['host'] - default_port = int(_captcha_config['server']['port']) - default_workers = int(_captcha_config['server']['workers']) - - parser = argparse.ArgumentParser(description="captcha — pymsg 验证码服务") - parser.add_argument("--addr", default=default_addr, help="监听地址") - parser.add_argument("--port", type=int, default=default_port, help="监听端口") - parser.add_argument("--workers", type=int, default=default_workers, - help="回调并发线程数(0=同步串行模式)") - args = parser.parse_args() - - server = CaptchaServer(args.addr, args.port, workers=args.workers) - print(f'[captcha] 配置来源: {_config_source}') - try: - server.start() - except KeyboardInterrupt: - server.stop() diff --git a/captcha/log_client.py b/captcha/log_client.py deleted file mode 100644 index f192d69..0000000 --- a/captcha/log_client.py +++ /dev/null @@ -1,4 +0,0 @@ -"""日志客户端 — 委托到 pymsg.logc 统一库。""" -from pymsg.logc import LogClient, set_log_level # noqa: F401 - -logs = LogClient(service_name="captcha") diff --git a/captcha/requirements.txt b/captcha/requirements.txt deleted file mode 100644 index a7f5d85..0000000 --- a/captcha/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -../pymsg -Pillow