diff --git a/confs/configs/gateway.ini b/confs/configs/gateway.ini new file mode 100644 index 0000000..8f3ead1 --- /dev/null +++ b/confs/configs/gateway.ini @@ -0,0 +1,53 @@ +[Server] +# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: +# - tls = off 启用 HTTP 模式(配合 waf 模块使用) +# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +address = 127.0.0.1 +port = 8443 +tls = off + +[Proxy] +default = http://127.0.0.1:9200 +closed = http://127.0.0.1:8005;http://127.0.0.1:8007; +opened = http://127.0.0.1:9200;http://127.0.0.1:8006; + +[Token] +# Token 有效期(秒),默认 3600 = 1 小时 +keep_time = 3600 +# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2 +refresh_grace_multiplier = 2 +# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10 +max_refresh = 10 + +[Admin] +# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true, +# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。 +# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。 +users = qing@qsont.me;shufeng@qsont.me + +[CORS] +# 允许跨域的 Origin 白名单(分号分隔) +# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) +allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 + +[Database] +path = sqlite3.db + +[WsProxy] +# WebSocket 反向代理路由(仅管理员可访问) +# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 +# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), +# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 +# 支持 ws:// 和 wss:// 后端 +# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws +routes = /ws/aibox=ws://127.0.0.1:8765; + +[Downstream] +# 下游 pymsg 服务地址(其他微服务的 host:port) +# 环境变量可覆盖:DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT, CONFS_HOST/CONFS_PORT +db_host = 127.0.0.1 +db_port = 8005 +captcha_host = 127.0.0.1 +captcha_port = 8009 +smtp_host = 127.0.0.1 +smtp_port = 8008 diff --git a/gateway/README.md b/gateway/README.md index df0ecef..0993710 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -40,12 +40,13 @@ gateway 通过项目内的 [pymsg](../pymsg/)(长度前缀帧 TCP 协议)与 **下游服务调用方式**: -| 下游 | 端口 | `_route` | 调用位置 | 重试策略 | -|------|------|----------|---------|---------| +| 下游 | 默认端口 | `_route` | 调用位置 | 重试策略 | +|------|---------|----------|---------|---------| | db | 8005 | `execute` | `DbC::call_msg()`(所有数据库 CRUD) | `retry_on_refused=true`,db 未就绪时阻塞等待 | | db | 8005 | `execute` | `LogC::_send()`(日志异步批量推送) | `retry_on_refused=false`,fail-safe 静默丢弃 | | captcha | 8009 | `verify` / `image` | `verify_captcha_remote()`、`/captcha/image` 代理 | 默认不重试,连接失败 fail-closed 返回 502/false | | smtp | 8008 | `verify_email` / `send_email` | `verify_email_code_remote()`、`/register/send_code` | 默认不重试,连接失败 fail-closed 返回 502/false | +| confs | 8010 | `get` | 启动时拉取 `gateway.ini` 配置 | `retry_on_refused=true`,confs 未就绪时阻塞等待 | **retry_on_refused 机制**(`MsgClient::call()`,对应 Python 端 `MsgClient.call(retry_on_refused=True)`): - 仅对 `ECONNREFUSED`(目标端口无服务监听)重试,超时、连接重置、业务错误立即抛出 @@ -186,53 +187,25 @@ gateway 不直连数据库(早期直连本地 sqlite 的代码已注释保留 ## 配置 -[example.ini](example.ini): +gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/static)保持一致: -```ini -[Server] -# tls = off HTTP 模式(配合 waf 模块) -# tls = on HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) -address = 127.0.0.1 -port = 8443 -tls = off +1. **confs 统一配置服务**(默认模式):启动时从 confs(`127.0.0.1:8010`,可通过 `CONFS_HOST`/`CONFS_PORT` 环境变量覆盖)通过 pymsg `get` 路由拉取 `gateway.ini`,`retry_on_refused=true` 阻塞等待 confs 就绪。 +2. **本地文件覆盖**(开发/调试模式):启动时传 `-i ` 则跳过 confs,直接读取本地 ini 文件。 +3. **环境变量覆盖**(最高优先级,Docker 兼容):`LISTEN_ADDR`/`LISTEN_PORT`、`DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 覆盖 ini 中对应值。 +4. **完整性校验**:必需配置项缺失则启动失败。 -[Proxy] -default = http://127.0.0.1:9200 -closed = http://127.0.0.1:8005;http://127.0.0.1:8007; -opened = http://127.0.0.1:9200;http://127.0.0.1:8006; +配置文件存放于 [confs/configs/gateway.ini](../confs/configs/gateway.ini)([example.ini](example.ini) 为本地开发参考副本,内容一致,新增 `[Downstream]` 段)。 -[Token] -# Token 有效期(秒),默认 3600 = 1 小时 -keep_time = 3600 - -[Admin] -# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true -# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断 -users = admin@example.com - -[CORS] -# 允许跨域的 Origin 白名单(分号分隔) -# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) -allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;http://localhost:8443 - -[Database] -path = sqlite3.db - -[WsProxy] -# WebSocket 反向代理路由(仅管理员) -# 格式:/path=ws://host:port/target;/path2=wss://host2/target2 -# 支持 ws:// 和 wss:// 后端,握手阶段校验管理员 Token -# routes = /ws/aibox=ws://127.0.0.1:8765;/ws/log=wss://example.com/log -routes = -``` - -- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。 +配置项说明: +- `Server.address`/`port`/`tls`:监听地址、端口、TLS 开关;`address`/`port` 可被 `LISTEN_ADDR`/`LISTEN_PORT` 环境变量覆盖。 +- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token)。 - `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 -- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 -- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。 -- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。 -- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。 -- `WsProxy.routes`:WebSocket 代理路由映射,格式 `/path=ws://host:port/target`,多个用分号分隔;留空则不开启 WS 代理。 +- `Proxy.closed`:仅文档记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 +- `Token.*`:Token 有效期(`keep_time`)、刷新宽限期倍数(`refresh_grace_multiplier`)、最大刷新次数(`max_refresh`)。 +- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。 +- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。 +- `WsProxy.routes`:WebSocket 代理路由,格式 `/path=ws://host:port/target`,分号分隔。 +- `Downstream.*`:下游 pymsg 服务地址(db/captcha/smtp),可被 `DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 环境变量覆盖。 ## 部署模式 @@ -275,42 +248,43 @@ tls = on - 项目内库:`libus_http`、`libus_crypt`(由 [libhttp](../libhttp/)、[libcrypt](../libcrypt/) 编译安装) ```bash -bash _set.sh # make clear && make -bash _run.sh # ./build/gateway.out -i example.ini +bash _set.sh # make clean && make +bash _run.sh # 默认从 confs 拉取配置;开发模式可用 _run.sh -i example.ini ``` -`gateway.out` 需以 `-i ` 传配置文件路径。其他参数:`-h/--help`、`-v/--version`。 +默认无需传参(从 confs 拉取配置);`-i ` 为本地文件模式(开发/调试用)。其他参数:`-h/--help`、`-v/--version`。 ## 目录结构 ``` gateway/ ├── src/ -│ └── main.cpp # 全部实现:PBKDF2 / PasswordValidator / RateLimiter / 路由 / handler +│ └── main.cpp # 全部实现:配置加载(confs/本地文件/环境变量)、PBKDF2、RateLimiter、路由/handler ├── inc/ -│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused 重试)、MsgException 族 -│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2 execute 路由(retry_on_refused=true) -│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe,不重试) -│ ├── Conf.hpp # ConfIni:基于 boost::property_tree 的 ini 解析 +│ ├── Msg.hpp # pymsg C++ 实现:长度前缀帧协议、MsgClient(含 retry_on_refused)、MsgException 族 +│ ├── DbC.hpp # db 服务客户端:通过 pymsg 调 dbv2(地址可配置,retry_on_refused=true) +│ ├── LogC.hpp # 统一日志客户端:异步批量队列 + pymsg INSERT(fail-safe) +│ ├── Conf.hpp # ConfIni:boost::property_tree ini 解析,支持 fromString/env/env_int │ ├── ExpBase.hpp # 异常基类(带 source_location) │ └── LocalTime.hpp # 本地时间工具(getCurrent / parse) +├── example.ini # 本地开发配置参考副本(与 confs/configs/gateway.ini 一致) ├── Makefile -├── example.ini ├── _set.sh / _run.sh └── README.md ``` ## 版本 -`0.6.1`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: +`0.7.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: ``` -Gateway 0.6.1 started +Gateway 0.7.0 started ``` 近期变更: -- `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制,与 Python 端 `pymsg.MsgClient.call(retry_on_refused=True)` 行为对齐——新增 `MsgConnectionRefusedException` 异常类(区分 ECONNREFUSED 与其他连接错误);`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数,仅对连接拒绝重试,固定间隔 1s,节制日志(首次立即打印 + 每 5s 一次),其他异常(超时/连接重置/业务错误)立即抛出;`DbC::call_msg()` 启用 `retry_on_refused=true`(db 是硬依赖,启动顺序不确定时自动阻塞等待);`LogC::_send()` 显式传 `retry_on_refused=false`(日志是 fail-safe,db 不可用时静默丢弃,绝不阻塞后台线程);captcha/smtp 辅助服务保持默认不重试(fail-fast,请求线程中永久阻塞会耗尽线程池)。 +- `0.7.0`:配置体系迁移到 confs 统一配置服务——新增 `confs/configs/gateway.ini`(原 example.ini 内容 + `[Downstream]` 段配置下游服务地址);启动时默认从 confs(`CONFS_HOST`/`CONFS_PORT`,默认 `127.0.0.1:8010`)通过 pymsg `get` 路由拉取配置(`retry_on_refused=true`),`-i ` 保留为本地文件开发模式;`ConfIni` 增加 `fromString()`(从 ini 文本解析)、`env()`/`env_int()`(环境变量读取)、公开默认构造函数;环境变量覆盖支持 `LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`CAPTCHA_HOST`/`CAPTCHA_PORT`/`SMTP_HOST`/`SMTP_PORT`;配置完整性校验(`Server.address`/`port`、`Proxy.default`、`Downstream.db_host`/`db_port` 缺失则启动失败);`DbC` 增加 `init(host, port)` 支持可配置 db 地址(默认 127.0.0.1:8005),main.cpp 中所有硬编码 captcha/smtp 地址改为配置变量,`logs().set_endpoint()` 在启动时初始化;`_run.sh` 默认不传 `-i`,`--version/-v` 选项补全实现。 +- `0.6.1`:pymsg 客户端增加 `retry_on_refused` 重试机制——新增 `MsgConnectionRefusedException`;`MsgClient::call()` 新增 `retry_on_refused`/`retry_interval` 参数;`DbC::call_msg()` 启用重试等待 db 就绪;`LogC::_send()` 显式不重试(fail-safe)。 - `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 - `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 - `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 diff --git a/gateway/_run.sh b/gateway/_run.sh index dcbe665..e5363c1 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -1,4 +1,10 @@ #!/bin/bash +# gateway 启动脚本 +# 默认从 confs 统一配置服务拉取配置(confs 需已启动)。 +# 若需使用本地 ini 文件(开发/调试),传入 -i ,例如:./_run.sh -i example.ini cd "$(dirname "$0")" || exit + +# 等待依赖服务启动(confs/db) sleep 2 -exec ./build/gateway.out -i example.ini #1>/dev/null + +exec ./build/gateway.out "$@" diff --git a/gateway/inc/Conf.hpp b/gateway/inc/Conf.hpp index 6e8ce6c..a0b7d82 100644 --- a/gateway/inc/Conf.hpp +++ b/gateway/inc/Conf.hpp @@ -1,6 +1,9 @@ #pragma once #include #include +#include +#include +#include #include #include namespace property_tree = boost::property_tree; @@ -9,22 +12,54 @@ namespace qing { class ConfIni { public: + /// 默认构造(空配置,后续通过赋值覆盖,仅供 confs 加载流程内部使用) + ConfIni() = default; + + /// 从文件路径解析 ini(保留用于开发/调试模式) ConfIni(const std::string &filename) { + if (filename.empty()) return; // 空文件名不报错,后续会通过赋值覆盖 try { - std::cout << "open and parse ini file: " << filename << std::endl; + std::cout << "[conf] 从文件加载配置: " << filename << std::endl; property_tree::ini_parser::read_ini(filename, pt); } catch (const boost::property_tree::ini_parser_error& e) { - std::cerr << "INI parsing error: " << e.what() << std::endl; + std::cerr << "[conf] INI 解析错误: " << e.what() << std::endl; throw IniConfigureFileParseException("INI parsing error!"); } } + /// 从 ini 文本字符串解析(用于从 confs 拉取后解析) + static ConfIni fromString(const std::string &ini_text) { + ConfIni c; + try { + std::istringstream iss(ini_text); + property_tree::ini_parser::read_ini(iss, c.pt); + } + catch (const boost::property_tree::ini_parser_error& e) { + std::cerr << "[conf] INI 文本解析错误: " << e.what() << std::endl; + throw IniConfigureFileParseException("INI parsing error!"); + } + return c; + } + template T get(const std::string &path, const T &_defaut) { return pt.get(path, _defaut); } + /// 读取环境变量,不存在则返回默认值 + static std::string env(const char *name, const std::string &default_val = "") { + const char *v = std::getenv(name); + return v ? std::string(v) : default_val; + } + + static int env_int(const char *name, int default_val) { + const char *v = std::getenv(name); + if (!v || !*v) return default_val; + try { return std::stoi(v); } + catch (...) { return default_val; } + } + class IniConfigureFileParseException: public std::exception { public: explicit IniConfigureFileParseException(const std::string &msg) diff --git a/gateway/inc/DbC.hpp b/gateway/inc/DbC.hpp index dbbb559..796c468 100644 --- a/gateway/inc/DbC.hpp +++ b/gateway/inc/DbC.hpp @@ -10,6 +10,12 @@ namespace qing { * smtp/captcha 等其他下游的超时常量见 main.cpp。 */ static constexpr double DEFAULT_TIMEOUT = 5.0; + /// 初始化 db 服务地址(启动时从配置设置,默认 127.0.0.1:8005) + static void init(const std::string& host, int port) { + _host = host; + _port = port; + } + // 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。 // 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。 static json::value call_http(json::value& params) { @@ -18,29 +24,34 @@ namespace qing { client.set_header("Content-Type: application/json"); std::string payload = json::serialize(params); std::string res = ""; - client.post_stream_sync(_DBURL, payload, [&res](const auto& ret) { + std::string url = "http://" + _host + ":" + std::to_string(_port) + "/execute"; + client.post_stream_sync(url, payload, [&res](const auto& ret) { res += ret; }); return json::parse(res); } // 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。 - // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 - // {"success":..., "data":[...]}。 - // 响应格式与 call_http 兼容,调用方可直接替换。 - // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 - static json::value call_msg(const json::value& params, - double timeout = DEFAULT_TIMEOUT) { - json::value jv_route = params; - jv_route.as_object()["_route"] = "execute"; - pmc::net::MsgClient client("127.0.0.1", 8005, "gateway"); - std::string resp = client.call( - json::serialize(jv_route), timeout, - /*retry_on_refused=*/true); - return json::parse(resp); - } + // 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应 + // {"success":..., "data":[...]}。 + // 响应格式与 call_http 兼容,调用方可直接替换。 + // db 是强依赖,启用 retry_on_refused:db 未启动时阻塞等待,避免启动顺序问题。 + static json::value call_msg(const json::value& params, + double timeout = DEFAULT_TIMEOUT) { + json::value jv_route = params; + jv_route.as_object()["_route"] = "execute"; + pmc::net::MsgClient client(_host, _port, "gateway"); + std::string resp = client.call( + json::serialize(jv_route), timeout, + /*retry_on_refused=*/true); + return json::parse(resp); + } + + static const std::string& host() { return _host; } + static int port() { return _port; } private: - static constexpr const char* _DBURL = "http://127.0.0.1:8005/execute"; + static inline std::string _host = "127.0.0.1"; + static inline int _port = 8005; }; } diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index dec0a30..0acaabe 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -28,7 +28,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.6.1"; +constexpr const char *_VER = "0.7.0"; ////////////////////////////////// #include @@ -339,6 +339,17 @@ static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数 static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin +/* 下游 pymsg 服务地址(从 confs [Downstream] 段加载,可被环境变量覆盖) */ +static std::string _DB_HOST = "127.0.0.1"; +static int _DB_PORT = 8005; +static std::string _CAPTCHA_HOST = "127.0.0.1"; +static int _CAPTCHA_PORT = 8009; +static std::string _SMTP_HOST = "127.0.0.1"; +static int _SMTP_PORT = 8008; +/* confs 引导配置(不依赖 confs 本身,环境变量 CONFS_HOST/CONFS_PORT 覆盖,默认 127.0.0.1:8010) */ +static std::string _CONFS_HOST = "127.0.0.1"; +static int _CONFS_PORT = 8010; + /* WebSocket 代理路由:path -> ws://backend-host:port/target * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ static std::unordered_map _WS_PROXY_ROUTES; @@ -414,7 +425,7 @@ bool verify_captcha(const std::string& token, const std::string& answer) { * 与内置 verify_captcha 并存:登录/注册可任选其一接入。 */ bool verify_captcha_remote(const std::string& token, const std::string& answer) { try { - pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); json::value jv_req = { {"_route", "verify"}, {"token", token}, @@ -430,11 +441,11 @@ bool verify_captcha_remote(const std::string& token, const std::string& answer) } } -/* 调用 smtp 服务(pymsg/TCP :8008)校验邮箱验证码。 +/* 调用 smtp 服务(pymsg/TCP)校验邮箱验证码。 * 验证码一次性消费;服务不可用或响应异常一律视为校验失败(fail-closed)。 */ bool verify_email_code_remote(const std::string& email, const std::string& code) { try { - pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); json::value jv_req = { {"_route", "verify_email"}, {"email", email}, @@ -475,7 +486,7 @@ int main(int argc, char **argv) { desc.add_options() ("help,h", "output help message") ("version,v", "output version message") - ("input,i", po::value(), "input a configure text file") + ("input,i", po::value(), "(可选) 指定本地 ini 配置文件路径,跳过 confs 拉取(开发/调试用)") ; @@ -501,21 +512,75 @@ int main(int argc, char **argv) { return 0; } - - - /* 异常:没输入配置文件 */ - if (!vm.count("input")) { - std::cerr << "Please input configure file via -i" << std::endl; - return 1; + /* 分支:输出版本号 */ + if (vm.count("version")) { + std::cout << "Gateway " << _VER << std::endl; + return 0; } - { - try { + /* ==================== 配置加载(两层:confs 强依赖 → 环境变量覆盖) ==================== + * 与 Python 服务(dnfs/db/static)保持一致的加载模式: + * 1. 引导配置:confs 地址从 CONFS_HOST/CONFS_PORT 环境变量读取,缺省 127.0.0.1:8010 + * 2. 从 confs 拉取 gateway.ini 全文(retry_on_refused=true 等待 confs 就绪) + * - 若指定了 -i 参数,则跳过 confs,直接读取本地 ini 文件(开发/调试模式) + * 3. 用 boost::property_tree 解析 ini + * 4. 环境变量覆盖(最高优先级,Docker 环境需要服务名而非 127.0.0.1) + * 5. 配置完整性校验:必需字段缺失则启动失败 + * ==================================================================================== */ - ConfIni cf(vm["input"].as()); + { + ConfIni cf; // 默认空构造,后续根据模式赋值 + + /* 1. 引导配置:confs 连接地址(不依赖 confs 本身,避免循环依赖) */ + _CONFS_HOST = ConfIni::env("CONFS_HOST", "127.0.0.1"); + _CONFS_PORT = ConfIni::env_int("CONFS_PORT", 8010); + + try { + if (vm.count("input")) { + /* 开发模式:-i 指定本地文件,跳过 confs */ + std::string ini_path = vm["input"].as(); + cf = ConfIni(ini_path); + std::cout << "[gateway] 配置来源: 本地文件 " << ini_path << std::endl; + } else { + /* 生产模式:从 confs 拉取 gateway.ini */ + std::cout << "[gateway] 从 confs (" << _CONFS_HOST << ":" << _CONFS_PORT + << ") 拉取 gateway.ini ..." << std::endl; + pmc::net::MsgClient confs_client(_CONFS_HOST, _CONFS_PORT, "gateway"); + json::object req; + req["_route"] = "get"; + req["name"] = "gateway.ini"; + std::string resp_str = confs_client.call( + json::serialize(req), /*timeout=*/5.0, + /*retry_on_refused=*/true); + auto resp = json::parse(resp_str).as_object(); + if (!resp.at("success").as_bool()) { + std::string err = "unknown"; + if (auto* ev = resp.if_contains("error")) { + if (ev->is_string()) err = ev->as_string().c_str(); + } + std::cerr << "[gateway] 致命错误: 从 confs 获取 gateway.ini 失败: " << err << std::endl; + return 1; + } + std::string ini_content = resp.at("data").as_object().at("content").as_string().c_str(); + cf = ConfIni::fromString(ini_content); + std::cout << "[gateway] 配置来源: confs (gateway.ini @ " + << _CONFS_HOST << ":" << _CONFS_PORT << ")" << std::endl; + } + } + catch (ConfIni::IniConfigureFileParseException &e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::cerr << "ini parse error" << std::endl; + return 1; + } + catch (const std::exception &e) { + std::cerr << "[gateway] 致命错误: 配置加载失败: " << e.what() << std::endl; + return 1; + } + + /* 2. 从 ini 读取配置 */ _ADDR = cf.get("Server.address", ""); - _PORT = cf.get ("Server.port", -1); - _TLS = cf.get ("Server.tls", false); + _PORT = cf.get("Server.port", -1); + _TLS = cf.get("Server.tls", false); _DEFT_URL = cf.get("Proxy.default", ""); _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); _DB_PATH = cf.get("Database.path", ""); @@ -525,8 +590,15 @@ int main(int argc, char **argv) { _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); - /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... - * 仅管理员(Token 中 is_admin=true)可访问,用于 aibox 等内部 WS 服务的网关暴露 */ + /* 下游服务地址 */ + _DB_HOST = cf.get("Downstream.db_host", "127.0.0.1"); + _DB_PORT = cf.get("Downstream.db_port", 8005); + _CAPTCHA_HOST = cf.get("Downstream.captcha_host", "127.0.0.1"); + _CAPTCHA_PORT = cf.get("Downstream.captcha_port", 8009); + _SMTP_HOST = cf.get("Downstream.smtp_host", "127.0.0.1"); + _SMTP_PORT = cf.get("Downstream.smtp_port", 8008); + + /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... */ { std::string ws_cfg = cf.get("WsProxy.routes", ""); if (!ws_cfg.empty()) { @@ -536,7 +608,6 @@ int main(int argc, char **argv) { if (eq != std::string::npos) { std::string path = entry.substr(0, eq); std::string url = entry.substr(eq + 1); - // 去除首尾空白 auto trim = [](std::string& s) { while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin()); while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); @@ -549,22 +620,53 @@ int main(int argc, char **argv) { } } } + + /* 3. 环境变量覆盖(优先级最高,Docker 环境使用服务名而非 127.0.0.1) */ + std::string env_listen_addr = ConfIni::env("LISTEN_ADDR"); + if (!env_listen_addr.empty()) _ADDR = env_listen_addr; + int env_listen_port = ConfIni::env_int("LISTEN_PORT", -1); + if (env_listen_port > 0) _PORT = env_listen_port; + + std::string env_db_host = ConfIni::env("DB_HOST"); + if (!env_db_host.empty()) _DB_HOST = env_db_host; + int env_db_port = ConfIni::env_int("DB_PORT", -1); + if (env_db_port > 0) _DB_PORT = env_db_port; + + std::string env_captcha_host = ConfIni::env("CAPTCHA_HOST"); + if (!env_captcha_host.empty()) _CAPTCHA_HOST = env_captcha_host; + int env_captcha_port = ConfIni::env_int("CAPTCHA_PORT", -1); + if (env_captcha_port > 0) _CAPTCHA_PORT = env_captcha_port; + + std::string env_smtp_host = ConfIni::env("SMTP_HOST"); + if (!env_smtp_host.empty()) _SMTP_HOST = env_smtp_host; + int env_smtp_port = ConfIni::env_int("SMTP_PORT", -1); + if (env_smtp_port > 0) _SMTP_PORT = env_smtp_port; + + /* 4. 配置完整性校验 */ + if (_ADDR.empty() || _PORT == -1) { + std::cerr << "[gateway] 致命错误: 配置项 [Server] address/port 缺失" + "(ini 中未配置且无 LISTEN_ADDR/LISTEN_PORT 环境变量覆盖)" << std::endl; + return 1; } - - - /* 常规输出 */ - catch (ConfIni::IniConfigureFileParseException &e) { - std::cerr << "ERROR: " << e.what() << std::endl; - std::cerr << "ini format input file error" << std::endl; + if (_DEFT_URL.empty()) { + std::cerr << "[gateway] 致命错误: 配置项 [Proxy] default 缺失" << std::endl; + return 1; + } + if (_DB_HOST.empty() || _DB_PORT <= 0) { + std::cerr << "[gateway] 致命错误: 配置项 [Downstream] db_host/db_port 缺失" << std::endl; return 1; } } - if (_ADDR == "" || _PORT == -1) { - std::cerr << "Please setup addree and port in Serve section in configure file"; - std::cerr << std::endl; - return 0; - } + /* 初始化下游客户端地址 */ + DbC::init(_DB_HOST, _DB_PORT); + logs().set_endpoint(_DB_HOST, _DB_PORT); + + std::cout << "[gateway] 监听: " << _ADDR << ":" << _PORT + << " (TLS: " << (_TLS ? "on" : "off") << ")" << std::endl; + std::cout << "[gateway] 下游服务: db=" << _DB_HOST << ":" << _DB_PORT + << ", captcha=" << _CAPTCHA_HOST << ":" << _CAPTCHA_PORT + << ", smtp=" << _SMTP_HOST << ":" << _SMTP_PORT << std::endl; // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 @@ -1373,7 +1475,7 @@ http::response handleCaptchaImage( res.version(req.version()); /* 调 captcha 服务(pymsg/TCP),连接失败/超时视为 502 */ - pmc::net::MsgClient client("127.0.0.1", 8009, "gateway"); + pmc::net::MsgClient client(_CAPTCHA_HOST, _CAPTCHA_PORT, "gateway"); std::string resp; try { resp = client.call(R"({"_route":"image"})", _TIMEOUT_CAPTCHA); @@ -1571,7 +1673,7 @@ http::response handleSendEmailCode( int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端) try { - pmc::net::MsgClient client("127.0.0.1", 8008, "gateway"); + pmc::net::MsgClient client(_SMTP_HOST, _SMTP_PORT, "gateway"); json::value jv_req = { {"_route", "send_email"}, {"email", email}