Skip to main content

futucli/cmd/
gen_key.rs

1//! `futucli gen-key`: 生成新 API Key 并追加到 keys.json
2//!
3//! 明文 key 只会打印到 stdout 一次,用户必须立即保存;文件中只存 SHA-256 hash。
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow};
9use chrono::{DateTime, Duration, Utc};
10use futu_auth::{KeyRecord, Limits, Scope, machine, store};
11
12use crate::output::OutputFormat;
13
14/// 默认 keys.json 路径:按 OS 取 dirs::config_dir() (macOS ~/Library/Application Support/futu/ / Linux ~/.config/futu/ / Windows %APPDATA%\\futu\\)
15fn default_keys_path() -> Result<PathBuf> {
16    let base =
17        dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
18    Ok(base.join("futu").join("keys.json"))
19}
20
21/// 探测 `futu-mcp` 的绝对路径,生成 gen-key 输出里的 MCP 配置模板时用。
22///
23/// v1.4.37+ UX 改进:v1.4.36 之前模板里是 hardcode `"/abs/path/to/futu-mcp"` 占位
24/// 符,加拿大同事反馈 "看起来像真路径,我直接粘贴了"。现在优先探测真实路径,
25/// 让复制粘贴即可用。
26///
27/// 探测顺序:
28/// 1. `futucli` 同目录下的 `futu-mcp`(Homebrew / cargo install / release tarball
29///    几乎 100% 同路径)
30/// 2. `$PATH` lookup(PATH 环境里第一个含 futu-mcp 的目录)
31/// 3. 都找不到 → 返 None,调用方 fallback 到显眼占位符
32fn detect_futu_mcp_path() -> Option<PathBuf> {
33    let exe_name = if cfg!(windows) {
34        "futu-mcp.exe"
35    } else {
36        "futu-mcp"
37    };
38
39    // 1. futucli 同目录
40    if let Ok(cli) = std::env::current_exe()
41        && let Some(dir) = cli.parent()
42    {
43        let candidate = dir.join(exe_name);
44        if candidate.is_file() {
45            return Some(candidate);
46        }
47    }
48
49    // 2. $PATH lookup
50    if let Some(path_env) = std::env::var_os("PATH") {
51        for dir in std::env::split_paths(&path_env) {
52            let candidate = dir.join(exe_name);
53            if candidate.is_file() {
54                return Some(candidate);
55            }
56        }
57    }
58
59    None
60}
61
62/// 解析 `--expires` 参数:`30d` / `24h` / `2026-06-30T23:59:59Z`
63fn parse_expires(s: &str) -> Result<DateTime<Utc>> {
64    let s = s.trim();
65    if let Ok(t) = DateTime::parse_from_rfc3339(s) {
66        return Ok(t.with_timezone(&Utc));
67    }
68    // 相对时长:<N>d / <N>h / <N>m
69    let (num_part, unit) = s
70        .chars()
71        .position(|c| c.is_alphabetic())
72        .map(|i| (&s[..i], &s[i..]))
73        .ok_or_else(|| anyhow!("invalid expires {s:?}: expect Nd / Nh / Nm / RFC3339"))?;
74    let n: i64 = num_part
75        .parse()
76        .map_err(|e| anyhow!("invalid number in expires {s:?}: {e}"))?;
77    let dur = match unit {
78        "d" => Duration::days(n),
79        "h" => Duration::hours(n),
80        "m" => Duration::minutes(n),
81        other => return Err(anyhow!("unknown expires unit {other:?} (d|h|m)")),
82    };
83    Ok(Utc::now() + dur)
84}
85
86fn parse_scopes(s: &str) -> Result<HashSet<Scope>> {
87    let mut out = HashSet::new();
88    for part in s.split(',') {
89        let part = part.trim();
90        if part.is_empty() {
91            continue;
92        }
93        let sc: Scope = part
94            .parse()
95            .map_err(|e| anyhow!("parse scope {part:?}: {e}"))?;
96        out.insert(sc);
97    }
98    if out.is_empty() {
99        return Err(anyhow!("--scopes cannot be empty"));
100    }
101    Ok(out)
102}
103
104// v1.4.106 codex 0608 F5 (P3): parse_csv (loose any-string-OK helper) 删除,
105// 改为 cmd::key_enums::parse_markets_csv / parse_symbols_csv / parse_trd_sides_csv
106// (strict, 拒绝未知 enum / 缺市场前缀的 symbol). 早期 reject 取代 silent stash —
107// schema-runtime layer 双对齐 (pitfall #54).
108
109/// v1.4.35: 解析 `--allowed-acc-ids 10001,10002,10003` 到 `HashSet<u64>`
110///
111/// 空条目跳过(方便 trailing comma);非数字 token 直接返错(防止用户误传
112/// symbol / market 到 acc-id 字段)。
113fn parse_acc_ids_csv(s: &str) -> Result<HashSet<u64>> {
114    let mut out = HashSet::new();
115    for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
116        let id: u64 = token.parse().map_err(|e| {
117            anyhow::anyhow!(
118                "invalid acc_id {token:?}: expected positive integer, got {e}. \
119                 Usage: --allowed-acc-ids 10001,10002,10003"
120            )
121        })?;
122        out.insert(id);
123    }
124    Ok(out)
125}
126
127pub struct GenKeyCommand {
128    pub id: String,
129    pub scopes: String,
130    pub keys_file: Option<PathBuf>,
131    pub expires: Option<String>,
132    pub note: Option<String>,
133    pub allowed_markets: Option<String>,
134    pub allowed_symbols: Option<String>,
135    pub max_order_value: Option<f64>,
136    pub max_daily_value: Option<f64>,
137    pub hours_window: Option<String>,
138    pub max_orders_per_minute: Option<u32>,
139    pub allowed_trd_sides: Option<String>,
140    /// v1.4.35:per-key 允许的 acc_id 列表,逗号分隔(如 `10001,10002`)。
141    /// None / 空 → 不限(向后兼容)。主要用于多 agent 隔离。
142    pub allowed_acc_ids: Option<String>,
143    /// v1.4.103 (B10):per-key 允许的 card_num 列表,逗号分隔.
144    /// 接受 4 位 suffix (e.g. `<card-suffix>`) 或 16 位完整 (e.g. `<full-card-num>`).
145    /// 示例为 synthetic placeholder, 不是真实账户信息.
146    /// daemon 启动后通过 GetAccList resolve → 合并进 allowed_acc_ids.
147    pub allowed_card_nums: Option<String>,
148    pub bind_this_machine: bool,
149    pub bind_machines: Option<String>,
150    pub output: OutputFormat,
151}
152
153pub async fn run(input: GenKeyCommand) -> Result<()> {
154    let GenKeyCommand {
155        id,
156        scopes,
157        keys_file,
158        expires,
159        note,
160        allowed_markets,
161        allowed_symbols,
162        max_order_value,
163        max_daily_value,
164        hours_window,
165        max_orders_per_minute,
166        allowed_trd_sides,
167        allowed_acc_ids,
168        allowed_card_nums,
169        bind_this_machine,
170        bind_machines,
171        output,
172    } = input;
173
174    let path = match keys_file {
175        Some(p) => p,
176        None => default_keys_path()?,
177    };
178    let scopes = parse_scopes(&scopes)?;
179    let expires_at = match expires {
180        Some(s) => Some(parse_expires(&s)?),
181        None => None,
182    };
183
184    // 交易方向白名单:v1.4.106 F5 strict parser — 大写归一化 + 拒绝未知
185    // trd_side (BUY/SELL/SELL_SHORT/BUY_BACK 4 variants 严格匹配, 不接受
186    // "long" / "exit" 等 silent stash).
187    let allowed_trd_sides = match allowed_trd_sides {
188        Some(s) => Some(crate::cmd::key_enums::parse_trd_sides_csv(&s)?),
189        None => None,
190    };
191
192    // v1.4.35: per-key acc_id 白名单,CSV 解析到 u64 HashSet
193    let allowed_acc_ids = match allowed_acc_ids {
194        Some(s) => Some(parse_acc_ids_csv(&s)?),
195        None => None,
196    };
197
198    // v1.4.103 (B10): per-key card_num 白名单, CSV 解析到 Vec<String>.
199    // 字符串形式保留 (4 位 suffix / 16 位完整), daemon 启动后 resolve.
200    //
201    // v1.4.104 external reviewer P2-008 (P2) fix: 显式传 "" / "  " / "," / 全空 CSV 时,
202    // .filter() 后 Vec 为 [], daemon 把空 list 当 "无限制" sentinel 处理 →
203    // 与用户 "完全不允许" 意图相反 (silent inverse). loud reject — 用户必须
204    // 要么不传 (Option::None → 未限制) 要么传至少 1 个 card_num.
205    let allowed_card_nums: Option<Vec<String>> = match allowed_card_nums {
206        None => None,
207        Some(s) => {
208            let parsed: Vec<String> = s
209                .split(',')
210                .map(|p| p.trim().to_string())
211                .filter(|p| !p.is_empty())
212                .collect();
213            if parsed.is_empty() {
214                return Err(anyhow!(
215                    "v1.4.104 external report P2-008 (P2) fix: --allowed-card-nums {s:?} parsed to empty \
216                     list. daemon 会把空 list 当 \"无限制\" sentinel — 与你的意图相反. \
217                     如要 \"不限制\" 请**不传** --allowed-card-nums; 如要 \"完全限制\" 至少 \
218                     传 1 个真实 4/16 位 card_num (即使是 dummy 0000)."
219                ));
220            }
221            Some(parsed)
222        }
223    };
224    // 校验格式: 4 位或 16 位纯数字
225    if let Some(ref nums) = allowed_card_nums {
226        for cn in nums {
227            if !cn.chars().all(|c| c.is_ascii_digit()) || (cn.len() != 4 && cn.len() != 16) {
228                return Err(anyhow!(
229                    "invalid card_num {cn:?}: expected 4-digit suffix \
230                     or 16-digit full card number (synthetic example: 4-digit \
231                     `<card-suffix>` or 16-digit `<full-card-num>`). \
232                     Got len={}, all-digits={}",
233                    cn.len(),
234                    cn.chars().all(|c| c.is_ascii_digit())
235                ));
236            }
237        }
238    }
239
240    // v1.4.106 codex 0608 F5 (P3): strict parser 替代 parse_csv —
241    // - markets: official TrdMarket aliases/int whitelist, including 10.6
242    //   Crypto / futures simulate / SG-MY-JP fund read scopes.
243    // - symbols: MARKET.CODE 必须含 '.'; market 部分在白名单
244    // 早期 reject 不合法 input, 不再 silent stash.
245    let allowed_markets = match allowed_markets {
246        Some(s) => Some(crate::cmd::key_enums::parse_markets_csv(&s)?),
247        None => None,
248    };
249    let allowed_symbols = match allowed_symbols {
250        Some(s) => Some(crate::cmd::key_enums::parse_symbols_csv(&s)?),
251        None => None,
252    };
253
254    let limits = Limits {
255        allowed_markets,
256        allowed_symbols,
257        max_order_value,
258        max_daily_value,
259        hours_window,
260        max_orders_per_minute,
261        allowed_trd_sides,
262        allowed_acc_ids,
263        allowed_card_nums,
264    };
265    let limits_for_output = limits.clone();
266
267    // 机器绑定:--bind-this-machine 和 --bind-machines 可同时使用,最终合并去重
268    let allowed_machines =
269        build_allowed_machines(&id, bind_this_machine, bind_machines.as_deref())?;
270
271    let (plaintext, record) = KeyRecord::generate_with_machines(
272        id.clone(),
273        scopes.clone(),
274        Some(limits),
275        expires_at,
276        note,
277        allowed_machines.clone(),
278    );
279
280    store::append_key(&path, record).with_context(|| format!("append to {}", path.display()))?;
281
282    print_result(
283        KeyPrintView {
284            path: &path,
285            id: &id,
286            plaintext: &plaintext,
287            scopes: &scopes,
288            allowed_machines: allowed_machines.as_deref(),
289            limits: &limits_for_output,
290        },
291        output,
292    )?;
293    Ok(())
294}
295
296/// 组装 `allowed_machines` 列表
297///
298/// - `bind_this_machine=true` → 读本机 machine-id,算 fingerprint_for(id)
299/// - `bind_machines=Some("fp1,fp2")` → 解析逗号分隔列表
300/// - 两者都没 → 返回 None(不启用绑定)
301///
302/// v1.4.106 codex 0608 F4 (P2): `--bind-machines ""` / `--bind-machines ", ,"`
303/// 等显式传空 CSV (parse 后 0 fingerprint) 且未传 `--bind-this-machine` →
304/// **loud reject** 不再 silent fall-through 到 None (None = "不启用绑定" =
305/// "无限制" silent inverse). 用户必须要么不传 `--bind-machines` 要么传至少
306/// 1 个真实指纹. `--freeze` 走 bind-key 独立路径 (允许显式空白名单).
307///
308/// v1.4.106 codex 0608 F5 (P3): fingerprint 解析改用 cmd::key_enums::
309/// parse_fingerprints_csv (与 bind-key 共用), 单一 source of truth.
310fn build_allowed_machines(
311    id: &str,
312    bind_this: bool,
313    bind_others: Option<&str>,
314) -> Result<Option<Vec<String>>> {
315    let mut list: Vec<String> = Vec::new();
316    if bind_this {
317        let fp = machine::fingerprint_for(id)
318            .map_err(|e| anyhow!("cannot compute this machine's fingerprint: {e}"))?;
319        list.push(fp);
320    }
321    if let Some(raw) = bind_others {
322        let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
323        if parsed.is_empty() && !bind_this {
324            // F4 (P2): 显式 --bind-machines ""/" ,," 但没配 --bind-this-machine →
325            // list 会留空 → 返 None → daemon 解 "无限制" → silent inverse.
326            // loud reject — 用户必须要么不传 --bind-machines 要么传至少 1 个 fp.
327            return Err(anyhow!(
328                "v1.4.106 F4: --bind-machines {raw:?} parsed to empty list \
329                 (no --bind-this-machine either). 这会让 allowed_machines = None \
330                 (无机器绑定限制) — 与你的意图相反. 如要 \"不启用绑定\" 请不传 \
331                 --bind-machines; 如要至少 1 个机器, 传至少 1 个 64-hex 指纹 \
332                 (`futucli machine-id --for-key <id>`)."
333            ));
334        }
335        list.extend(parsed);
336    }
337    if list.is_empty() {
338        return Ok(None);
339    }
340    // 去重保序
341    let mut seen = HashSet::new();
342    list.retain(|x| seen.insert(x.clone()));
343    Ok(Some(list))
344}
345
346struct KeyPrintView<'a> {
347    path: &'a Path,
348    id: &'a str,
349    plaintext: &'a str,
350    scopes: &'a HashSet<Scope>,
351    allowed_machines: Option<&'a [String]>,
352    limits: &'a Limits,
353}
354
355fn print_result(view: KeyPrintView<'_>, output: OutputFormat) -> Result<()> {
356    let rendered = render_result(view, output, detect_futu_mcp_path().as_deref())?;
357    println!("{rendered}");
358    Ok(())
359}
360
361fn render_result(
362    view: KeyPrintView<'_>,
363    output: OutputFormat,
364    mcp_path: Option<&Path>,
365) -> Result<String> {
366    use std::fmt::Write as _;
367
368    let mut scope_list: Vec<&str> = view.scopes.iter().map(|s| s.as_str()).collect();
369    scope_list.sort_unstable();
370    let mcp_command = mcp_path
371        .map(|path| path.display().to_string())
372        .unwrap_or_else(|| "REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp".to_string());
373
374    if matches!(output, OutputFormat::Json | OutputFormat::Jsonl) {
375        let limits = serde_json::json!({
376            "allowed_markets": view.limits.allowed_markets,
377            "allowed_symbols": view.limits.allowed_symbols,
378            "max_order_value": view.limits.max_order_value,
379            "max_daily_value": view.limits.max_daily_value,
380            "hours_window": view.limits.hours_window,
381            "max_orders_per_minute": view.limits.max_orders_per_minute,
382            "allowed_trd_sides": view.limits.allowed_trd_sides,
383            "allowed_acc_ids": view.limits.allowed_acc_ids,
384            "allowed_card_nums": view.limits.allowed_card_nums,
385        });
386        let value = serde_json::json!({
387            "id": view.id,
388            "scopes": scope_list,
389            "keys_file": view.path.display().to_string(),
390            "plaintext": view.plaintext,
391            "limits": limits,
392            "allowed_machines": view.allowed_machines,
393            "mcp_config": {
394                "mcpServers": {
395                    "futu": {
396                        "command": mcp_command,
397                        "args": ["--keys-file", view.path.display().to_string()],
398                        "env": {"FUTU_MCP_API_KEY": view.plaintext}
399                    }
400                }
401            }
402        });
403        return match output {
404            OutputFormat::Json => serde_json::to_string_pretty(&value).map_err(Into::into),
405            OutputFormat::Jsonl => serde_json::to_string(&value).map_err(Into::into),
406            _ => unreachable!(),
407        };
408    }
409
410    let mut rendered = String::new();
411    writeln!(rendered)?;
412    writeln!(rendered, "=== FutuOpenD-rs API Key ===")?;
413    writeln!(rendered)?;
414    writeln!(rendered, "  id     : {}", view.id)?;
415    writeln!(rendered, "  scopes : {}", scope_list.join(", "))?;
416    writeln!(rendered, "  path   : {}", view.path.display())?;
417    if let Some(r) = view.limits.max_orders_per_minute {
418        writeln!(rendered, "  rate   : {r} orders/min")?;
419    }
420    if let Some(sides) = view.limits.allowed_trd_sides.as_ref()
421        && !sides.is_empty()
422    {
423        let mut v: Vec<String> = sides.iter().cloned().collect();
424        v.sort();
425        writeln!(rendered, "  sides  : {}", v.join(","))?;
426    }
427    if let Some(ms) = view.allowed_machines {
428        writeln!(rendered, "  bound  : {} machine(s)", ms.len())?;
429        for fp in ms {
430            writeln!(rendered, "           {}", &fp[..16])?;
431        }
432    }
433    writeln!(rendered)?;
434    writeln!(
435        rendered,
436        "Plaintext (shown once, SAVE IT NOW — file only stores SHA-256 hash):"
437    )?;
438    writeln!(rendered)?;
439    writeln!(rendered, "  FUTU_MCP_API_KEY={}", view.plaintext)?;
440    writeln!(rendered)?;
441    writeln!(
442        rendered,
443        "Add to your MCP client config, e.g. Claude Desktop claude_desktop_config.json:"
444    )?;
445    writeln!(rendered)?;
446    // v1.4.37+ UX:优先探测 futu-mcp 真实路径,fallback 给显眼警告占位符。
447    // 加拿大同事反馈:v1.4.36 之前的 "/abs/path/to/futu-mcp" 像真路径,容易误
448    // 粘贴。现在如果 futu-mcp 和 futucli 装在一起(Homebrew / cargo install /
449    // release tarball 几乎总是如此),模板里就是可用的绝对路径 —— 复制粘贴即用。
450    let (mcp_command_json, post_note) = match mcp_path {
451        Some(path) => (
452            serde_json::to_string(&path.display().to_string())?,
453            format!("(auto-detected: {})", path.display()),
454        ),
455        None => (
456            r#""REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp""#.to_string(),
457            "⚠️  futu-mcp not found in PATH or next to futucli — replace the \
458                command field above with the absolute path (run: `which futu-mcp`)."
459                .to_string(),
460        ),
461    };
462    writeln!(rendered, "  {{")?;
463    writeln!(rendered, "    \"mcpServers\": {{")?;
464    writeln!(rendered, "      \"futu\": {{")?;
465    writeln!(rendered, "        \"command\": {mcp_command_json},")?;
466    writeln!(
467        rendered,
468        "        \"args\": [\"--keys-file\", \"{}\"],",
469        view.path.display()
470    )?;
471    writeln!(
472        rendered,
473        "        \"env\": {{ \"FUTU_MCP_API_KEY\": \"{}\" }}",
474        view.plaintext
475    )?;
476    writeln!(rendered, "      }}")?;
477    writeln!(rendered, "    }}")?;
478    writeln!(rendered, "  }}")?;
479    writeln!(rendered)?;
480    writeln!(rendered, "  command path: {post_note}")?;
481    Ok(rendered)
482}
483
484#[cfg(test)]
485mod tests;