futu_opend/cli.rs
1//! v1.4.110 P1-2: CLI 类型抽自 main.rs lines 125-496.
2//!
3//! Platform / LoginRegion / LogLevel enums + Args (clap derive).
4
5use clap::Parser;
6
7/// 账号平台(v1.4.14+)
8///
9/// 决定 HTTP 认证服务器域名。两个平台**独立账号体系**:
10/// - `futunn`(默认)—— 牛牛,用户为 CN / HK 归属,auth.futunn.com
11/// - `moomoo` —— moomoo,用户为 US / SG / AU / JP / CA 归属,auth.moomoo.com
12///
13/// 同一手机号 / 邮箱可以在两边分别注册独立账号(不同密码)。`--platform`
14/// 让用户显式选择,避免"同号两边都有"时我们默认发 futunn 登到错账号。
15///
16/// `--auth-server <url>` 显式指定 URL 时**覆盖** `--platform`(给测试环境用)。
17#[derive(Debug, Clone, Copy, clap::ValueEnum, Default, serde::Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "lowercase")]
19#[clap(rename_all = "lower")]
20pub enum Platform {
21 /// 牛牛(Futubull)—— CN / HK 账号
22 #[default]
23 Futunn,
24 /// moomoo —— US / SG / AU / JP / CA 账号
25 Moomoo,
26}
27
28impl Platform {
29 pub fn auth_server(self) -> &'static str {
30 match self {
31 Self::Futunn => "https://auth.futunn.com",
32 Self::Moomoo => "https://auth.moomoo.com",
33 }
34 }
35
36 pub fn name(self) -> &'static str {
37 match self {
38 Self::Futunn => "futunn",
39 Self::Moomoo => "moomoo",
40 }
41 }
42}
43
44/// v1.4.40 #12 fix (external reviewer exhaustive report): `--login-region` 封闭 enum。
45///
46/// v1.4.39 及以前接受任意字符串(`gz` / `us` / `moomoo` / `wuhan` / 日期串都被
47/// 静默吃掉,无法反馈给用户),且**对 moomoo 账户此 flag 永远不生效**(daemon 内
48/// commconfig 按 `user_attribution` 查 `guaranteed_ip` 覆盖)。
49///
50/// v1.4.40 起:
51/// - clap `ValueEnum` 只接受 `gz` / `sh` / `hk` 三个合法值,非法值 fail-fast
52/// - 对 `--platform moomoo` 启动时若用户传了 `--login-region`,WARN log 显式说明
53/// "此 flag 仅对 futunn 生效,moomoo 账户按 user_attribution 自动路由"
54///
55/// 三个值对应 futunn 平台的广州 / 上海 / 香港数据中心标识(C++ OpenD 历史约定)。
56#[derive(Debug, Clone, Copy, clap::ValueEnum, serde::Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58#[clap(rename_all = "lower")]
59pub enum LoginRegion {
60 /// futunn 广州数据中心(默认)
61 Gz,
62 /// futunn 上海数据中心
63 Sh,
64 /// futunn 香港数据中心
65 Hk,
66}
67
68impl LoginRegion {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Self::Gz => "gz",
72 Self::Sh => "sh",
73 Self::Hk => "hk",
74 }
75 }
76}
77
78/// v1.4.73 BUG-015 fix:`--log-level` silent accept 非法值(tracing-subscriber
79/// `EnvFilter::new()` 对非法值返"deny-all" filter 吞所有 log,exit=0 像正常跑
80/// 但用户看不到任何输出)。
81///
82/// 用 clap `ValueEnum` 约束有效值集合(对齐 v1.4.40 `LoginRegion` 做法),
83/// 非法值被 clap 在 parse 阶段拒绝 → exit=2 + 清晰错误输出。
84///
85/// 有效值 = `tracing` 标准 `LevelFilter` / `Level`:
86/// `trace` / `debug` / `info` / `warn` / `error` / `off`。
87#[derive(Debug, Clone, Copy, clap::ValueEnum, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "lowercase")]
89#[clap(rename_all = "lower")]
90pub enum LogLevel {
91 /// 最细粒度(含 tracing span entry/exit)
92 Trace,
93 /// 调试信息(默认最噪)
94 Debug,
95 /// 标准运行日志(默认)
96 Info,
97 /// 警告及以上
98 Warn,
99 /// 只有错误
100 Error,
101 /// 关闭所有日志
102 Off,
103}
104
105impl LogLevel {
106 pub fn as_str(self) -> &'static str {
107 match self {
108 Self::Trace => "trace",
109 Self::Debug => "debug",
110 Self::Info => "info",
111 Self::Warn => "warn",
112 Self::Error => "error",
113 Self::Off => "off",
114 }
115 }
116
117 /// 从 config 文件的 string 解析(XML / TOML)—— 非 clap 路径使用。
118 /// 非法值返 None 让调用方 `eprintln!` + exit。
119 ///
120 /// codex 0547 F3 (P2) 之后: production 路径已改用 serde Option<LogLevel>
121 /// 直接 parse. 此 helper 只在测试里保留旧 alias 行为证据,避免把
122 /// "warning" / "silent" / "none" 重新接回 production 配置解析。
123 #[cfg(test)]
124 pub fn from_str_opt(s: &str) -> Option<Self> {
125 match s.trim().to_ascii_lowercase().as_str() {
126 "trace" => Some(Self::Trace),
127 "debug" => Some(Self::Debug),
128 "info" => Some(Self::Info),
129 "warn" | "warning" => Some(Self::Warn),
130 "error" => Some(Self::Error),
131 "off" | "none" | "silent" => Some(Self::Off),
132 _ => None,
133 }
134 }
135}
136
137/// FutuOpenD Rust Gateway — 完全替代 C++ OpenD
138#[derive(Parser)]
139#[command(name = "futu-opend", version, about = "FutuOpenD Rust Gateway")]
140pub struct Args {
141 /// XML 配置文件路径 (兼容 C++ FutuOpenD.xml)
142 #[arg(long)]
143 pub cfg_file: Option<String>,
144
145 /// TOML 配置文件路径 (v1.4.2+;字段与 CLI 参数对齐, CLI 参数覆盖).
146 ///
147 /// codex 0547 F6 (P3): 字段白名单 (与 `XmlConfig` schema 一致):
148 /// - 登录: `login_account` / `login_pwd` / `login_pwd_md5` /
149 /// `login_pwd_file` / `login_region` / `platform`
150 /// - 监听: `ip` / `port` (alias `api_port`) / `rest_port` /
151 /// `grpc_port` / `websocket_port` / `telnet_port`
152 /// - 安全: `rsa_private_key` / `rest_keys_file` / `grpc_keys_file` /
153 /// `ws_keys_file` / `audit_log` / `allow_tcp_unauthenticated`
154 /// - 系统: `lang` / `log_level` / `tz`
155 ///
156 /// **不能写 TOML 的 CLI-only 字段** (运维 / 调试 / 一次性流程):
157 /// `device_id` / `reset_device` / `setup_only` / `verify_code` /
158 /// `json_log` / `inject_auth_failure_every` (dev-flags feature only)
159 ///
160 /// 任何 unknown field / typo 触发 fatal parse error (BUG-006
161 /// `deny_unknown_fields`), daemon abort. 不再 silent drop.
162 ///
163 /// 示例:
164 /// ```toml
165 /// login_account = "123456"
166 /// ip = "0.0.0.0"
167 /// port = 11111
168 /// rest_port = 22222
169 /// grpc_port = 33333
170 /// rest_keys_file = "/etc/futu/keys.json"
171 /// audit_log = "/var/log/futu-audit.jsonl"
172 /// tz = "Asia/Hong_Kong"
173 /// ```
174 #[arg(long)]
175 pub config: Option<String>,
176
177 /// API 服务监听地址
178 #[arg(short = 'i', long)]
179 pub ip: Option<String>,
180
181 /// API 服务监听端口
182 #[arg(short = 'p', long)]
183 pub port: Option<u16>,
184
185 /// 登录账号
186 #[arg(long)]
187 pub login_account: Option<String>,
188
189 /// 登录密码明文(legacy argv fallback;不建议生产使用:会暴露在 `ps` 输出
190 /// 和 shell history。优先用 `futucli set-login-pwd` 或 `--login-pwd-file`)
191 #[arg(long)]
192 pub login_pwd: Option<String>,
193
194 /// 登录密码 MD5 (32 位小写 hex;legacy argv fallback;不建议生产使用:
195 /// MD5 可直接登录,且同样会暴露在 `ps` 输出和 shell history)
196 #[arg(long)]
197 pub login_pwd_md5: Option<String>,
198
199 /// 登录密码从**文件**读(v1.4.18+)——适用于 systemd `LoadCredential=` /
200 /// Docker secrets 场景。argv 里只有文件路径,不会泄露明文。
201 ///
202 /// 文件内容:明文密码(末尾 `\n` 会被 trim 掉)。
203 #[arg(long)]
204 pub login_pwd_file: Option<String>,
205
206 /// 后端连接区域 (gz / sh / hk) —— **仅对 `--platform futunn` 生效**
207 ///
208 /// 这三个值是 **futunn 平台**的广州 / 上海 / 香港数据中心标识。
209 ///
210 /// **对 `--platform moomoo` 账户此 flag 会被忽略**:daemon 内 commconfig 根据
211 /// `user_attribution` 查 `guaranteed_ip` 列表覆盖。想切 moomoo 各区用
212 /// `--platform moomoo` + 账号本身决定归属。
213 ///
214 /// v1.4.40 起 clap 封闭 enum 拒绝非法值(v1.4.39 及以前静默接受任意字符串)。
215 #[arg(long, value_enum)]
216 pub login_region: Option<LoginRegion>,
217
218 /// 账号平台(v1.4.14+)—— futunn=牛牛/CN/HK,moomoo=US/SG/AU/JP/CA
219 ///
220 /// 同手机号 / 邮箱可以在两边各注册独立账号(不同密码)。默认 futunn。
221 /// `--auth-server` 显式指定 URL 时覆盖 `--platform`。
222 #[arg(long, value_enum)]
223 pub platform: Option<Platform>,
224
225 /// 认证服务器 URL(覆盖 `--platform` 推导的默认值,主要给测试环境用)
226 #[arg(long)]
227 pub auth_server: Option<String>,
228
229 /// 设备 ID(16 位 hex)—— 覆盖自动生成/持久化的值。
230 ///
231 /// v1.4.17+ 默认从 `~/.futu-opend-rs/device-{hash}.dat` 读(首次随机生成
232 /// 并写入)。本参数用于显式指定,并**更新**持久化文件。
233 ///
234 /// 如果 device_id 被服务端锁定(`error_code=15/21`),可用
235 /// `--reset-device` 一键清空文件让下次启动随机生成新值。
236 #[arg(long)]
237 pub device_id: Option<String>,
238
239 /// 重置 device_id + credentials 文件后再启动(v1.4.17+)
240 ///
241 /// 当用户的 device_id 因空验证码 / 多次 SMS 输错被服务端锁定,
242 /// 所有后续请求都返回 `error_code=15 长时间没有登录` 无法恢复。
243 /// 本参数删除 `~/.futu-opend-rs/device-{hash}.dat` 和
244 /// `credentials-{hash}.json`,下次 login 重新生成随机 device_id 走
245 /// 完整首登流程。
246 #[arg(long)]
247 pub reset_device: bool,
248
249 /// 只完成首次设备验证 + 凭据缓存后退出(v1.4.17+)
250 ///
251 /// 用于 systemd / Docker / cron 场景:先在**前台终端**手动跑一次
252 /// `futu-opend --setup-only` 完成 SMS 验证,写入 credentials 文件,然后
253 /// 生产环境启动时直接走 remember-login 跳过 SMS。
254 #[arg(long)]
255 pub setup_only: bool,
256
257 /// **v1.4.57 外部 UX-04**(加拿大同事 SMS + Telegram 中继场景必需):
258 /// 直接传入 SMS 验证码,跳过 stdin prompt。用于 agent / CI / 远程中继场景
259 /// (SMS 验证码通过 Telegram/IM 转发,60 秒失效,直接用 stdin 输入来不及)。
260 ///
261 /// 典型用法:
262 /// ```bash
263 /// # 1. 先不带 --verify-code 启动触发 SMS(会 fail + 退出,但 SMS 已发)
264 /// futu-opend --setup-only --login-account X --login-pwd Y
265 /// # 2. 收到 SMS 后立即带验证码重新启动
266 /// futu-opend --setup-only --login-account X --login-pwd Y --verify-code 123456
267 /// ```
268 #[arg(long)]
269 pub verify_code: Option<String>,
270
271 /// 日志级别(v1.4.73 BUG-015: clap ValueEnum 约束有效值 trace/debug/info/warn/error/off,
272 /// 非法值 parse 阶段拒绝 + 清晰错误,不再 silent 吞 log)
273 #[arg(long, value_enum)]
274 pub log_level: Option<LogLevel>,
275
276 /// WebSocket 服务监听端口(可选,不指定则不启动 WebSocket)
277 #[arg(long)]
278 pub websocket_port: Option<u16>,
279
280 /// Telnet 管理端口(可选,不指定则不启动 Telnet)
281 #[arg(long)]
282 pub telnet_port: Option<u16>,
283
284 /// REST API 监听端口(可选,不指定则不启动 REST API)
285 #[arg(long)]
286 pub rest_port: Option<u16>,
287
288 /// gRPC 服务监听端口(可选,不指定则不启动 gRPC)
289 #[arg(long)]
290 pub grpc_port: Option<u16>,
291
292 /// RSA 私钥文件路径(PEM 格式,启用后 InitConnect 使用 RSA 加解密)
293 #[arg(long)]
294 pub rsa_private_key: Option<String>,
295
296 /// JSON 格式日志
297 #[arg(long)]
298 pub json_log: bool,
299
300 /// 界面语言 (chs=简体中文, cht=繁体中文, en=英文)
301 #[arg(long)]
302 pub lang: Option<String>,
303
304 /// REST API Bearer Token 鉴权:加载 keys.json(futucli gen-key 生成)
305 ///
306 /// 不指定时 REST API 只读接口保持 legacy 无鉴权;写交易/admin 仍要求 API key。
307 #[arg(long)]
308 pub rest_keys_file: Option<std::path::PathBuf>,
309
310 /// gRPC Bearer Token 鉴权:加载 keys.json(futucli gen-key 生成)
311 ///
312 /// 不指定时 gRPC 无鉴权。通常与 --rest-keys-file 指向同一文件。
313 #[arg(long)]
314 pub grpc_keys_file: Option<std::path::PathBuf>,
315
316 /// 核心 WebSocket Bearer Token 鉴权:加载 keys.json
317 ///
318 /// v1.0 起核心 WS(`--websocket-port`,Futu SDK 使用的 binary WS)支持
319 /// 握手 + per-message scope 鉴权。客户端用 `?token=<plaintext>` query 或
320 /// `Authorization: Bearer <plaintext>` header 传 key。不指定这个 flag 时
321 /// WS 无鉴权(legacy 保持兼容,启动 warn)。通常与 `--rest-keys-file` 指向
322 /// 同一文件。
323 #[arg(long)]
324 pub ws_keys_file: Option<std::path::PathBuf>,
325
326 /// v1.4.104 external reviewer S-001 (P0) fix: native TCP (FTAPI port `--port`) 显式
327 /// 允许无 auth 接受连接.
328 ///
329 /// **背景**: native TCP FTAPI 协议 (Python SDK / C++ OpenD 用) 没有
330 /// Authorization header 概念, InitConnect proto 无 Bearer 字段. 加 keystore
331 /// 后无法做 caller-specific scope 检查.
332 ///
333 /// **默认行为 (v1.4.104+)**: 配置任一 keys file (`--rest-keys-file` /
334 /// `--grpc-keys-file` / `--ws-keys-file`) → daemon **关闭 TCP 端口**
335 /// (fail-closed, 防 v1.4.103 external reviewer S-001 跨 surface bypass).
336 ///
337 /// 显式 opt-in 此 flag → 保留 TCP 端口, daemon 启动 loud warn 用户该
338 /// 端口完全无 auth.
339 #[arg(long, default_value_t = false)]
340 pub allow_tcp_unauthenticated: bool,
341
342 /// 审计日志输出:JSONL 文件路径或目录
343 ///
344 /// - 带扩展名的路径(如 `/var/log/futu-audit.jsonl`)→ 单文件 append
345 /// - 不带扩展名 / 以 `/` 结尾(如 `/var/log/futu-audit/`)→ 每日滚动,
346 /// 文件名 `futu-audit.log` + 日期后缀
347 ///
348 /// 只记录 auth / 交易 事件(target = "futu_audit"),常规日志不受影响。
349 #[arg(long)]
350 pub audit_log: Option<std::path::PathBuf>,
351
352 /// v1.4.87 #3 G1: 时区覆盖 (IANA name, 如 "Asia/Hong_Kong" / "America/New_York")
353 ///
354 /// 用于 `hours_window` 限额检查等 "local time" 语义. 不指定时用系统 `TZ`
355 /// 环境变量, 仍未设则用 UTC. 典型场景:
356 ///
357 /// - Daemon 跑在 UTC server 但想 HK 交易时段限额 → `--tz Asia/Hong_Kong`
358 /// - Daemon 跑在 local workstation 且 `TZ` 已正确 → 不用 `--tz`
359 ///
360 /// 优先级: `--tz` flag > `TZ` env var > UTC.
361 #[arg(long, value_name = "IANA_TZ")]
362 pub tz: Option<String>,
363
364 /// **DEV-ONLY** v1.4.97 P1-D-C: 每 N 秒强制将 qot_logined 置 false 触发
365 /// P1-D self-heal ladder, 给 tester 真机 verify ladder 4 cell 用.
366 ///
367 /// 仅在 `cargo build --features dev-flags` 编译时可见. release build
368 /// (no feature) 不暴露此 flag. 防 production 误启用 (per 坑 #50 SPKI dev
369 /// pattern).
370 ///
371 /// **典型使用** (仅 tester 用):
372 /// ```bash
373 /// FUTU_QOT_RELOGIN_BACKOFF_MS=5000,10000,20000,40000 \
374 /// futu-opend --inject-auth-failure-every=10 --login-account ...
375 /// # 期望日志: P1-D ladder 5s → 10s → 20s → 40s 各 trigger 一次
376 /// ```
377 #[cfg(feature = "dev-flags")]
378 #[arg(long, value_name = "SECONDS", hide = false)]
379 pub inject_auth_failure_every: Option<u64>,
380}
381
382impl std::fmt::Debug for Args {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 let login_account_fp = self
385 .login_account
386 .as_deref()
387 .map(futu_backend::auth::redact::account_log_fingerprint);
388 let login_pwd = redact_debug_option(&self.login_pwd);
389 let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
390 let device_id_fp = self
391 .device_id
392 .as_deref()
393 .map(futu_backend::auth::redact::device_id_log_fingerprint);
394 let verify_code = redact_debug_option(&self.verify_code);
395
396 let mut debug = f.debug_struct("Args");
397 debug
398 .field("cfg_file", &self.cfg_file)
399 .field("config", &self.config)
400 .field("ip", &self.ip)
401 .field("port", &self.port)
402 .field("login_account_fp", &login_account_fp)
403 .field("login_pwd", &login_pwd)
404 .field("login_pwd_md5", &login_pwd_md5)
405 .field("login_pwd_file", &self.login_pwd_file)
406 .field("login_region", &self.login_region)
407 .field("platform", &self.platform)
408 .field("auth_server", &self.auth_server)
409 .field("device_id_fp", &device_id_fp)
410 .field("reset_device", &self.reset_device)
411 .field("setup_only", &self.setup_only)
412 .field("verify_code", &verify_code)
413 .field("log_level", &self.log_level)
414 .field("websocket_port", &self.websocket_port)
415 .field("telnet_port", &self.telnet_port)
416 .field("rest_port", &self.rest_port)
417 .field("grpc_port", &self.grpc_port)
418 .field("rsa_private_key", &self.rsa_private_key)
419 .field("json_log", &self.json_log)
420 .field("lang", &self.lang)
421 .field("rest_keys_file", &self.rest_keys_file)
422 .field("grpc_keys_file", &self.grpc_keys_file)
423 .field("ws_keys_file", &self.ws_keys_file)
424 .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
425 .field("audit_log", &self.audit_log)
426 .field("tz", &self.tz);
427
428 #[cfg(feature = "dev-flags")]
429 debug.field("inject_auth_failure_every", &self.inject_auth_failure_every);
430
431 debug.finish()
432 }
433}
434
435fn redact_debug_option(value: &Option<String>) -> String {
436 match value {
437 Some(value) => format!("<REDACTED len={}>", value.len()),
438 None => "<NONE>".to_string(),
439 }
440}
441
442#[cfg(test)]
443mod tests;