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