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 eli 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 eli 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: 12 variants 白名单 (HK/US/CN/HKCC/FUTURES/SG/AU/JP/MY/CA/HKFUND/USFUND)
238    // - symbols: MARKET.CODE 必须含 '.'; market 部分在白名单
239    // 早期 reject 不合法 input, 不再 silent stash.
240    let allowed_markets = match allowed_markets {
241        Some(s) => Some(crate::cmd::key_enums::parse_markets_csv(&s)?),
242        None => None,
243    };
244    let allowed_symbols = match allowed_symbols {
245        Some(s) => Some(crate::cmd::key_enums::parse_symbols_csv(&s)?),
246        None => None,
247    };
248
249    let limits = Limits {
250        allowed_markets,
251        allowed_symbols,
252        max_order_value,
253        max_daily_value,
254        hours_window,
255        max_orders_per_minute,
256        allowed_trd_sides,
257        allowed_acc_ids,
258        allowed_card_nums,
259    };
260
261    // 机器绑定:--bind-this-machine 和 --bind-machines 可同时使用,最终合并去重
262    let allowed_machines =
263        build_allowed_machines(&id, bind_this_machine, bind_machines.as_deref())?;
264
265    let (plaintext, record) = KeyRecord::generate_with_machines(
266        id.clone(),
267        scopes.clone(),
268        Some(limits),
269        expires_at,
270        note,
271        allowed_machines.clone(),
272    );
273
274    // 保留副本给 print_result 展示
275    let rate_summary = record.max_orders_per_minute;
276    let sides_summary: Option<Vec<String>> = record
277        .allowed_trd_sides
278        .as_ref()
279        .map(|s| s.iter().cloned().collect());
280
281    store::append_key(&path, record).with_context(|| format!("append to {}", path.display()))?;
282
283    print_result(KeyPrintView {
284        path: &path,
285        id: &id,
286        plaintext: &plaintext,
287        scopes: &scopes,
288        allowed_machines: allowed_machines.as_deref(),
289        max_orders_per_minute: rate_summary,
290        allowed_trd_sides: sides_summary.as_deref(),
291    });
292    Ok(())
293}
294
295/// 组装 `allowed_machines` 列表
296///
297/// - `bind_this_machine=true` → 读本机 machine-id,算 fingerprint_for(id)
298/// - `bind_machines=Some("fp1,fp2")` → 解析逗号分隔列表
299/// - 两者都没 → 返回 None(不启用绑定)
300///
301/// v1.4.106 codex 0608 F4 (P2): `--bind-machines ""` / `--bind-machines ", ,"`
302/// 等显式传空 CSV (parse 后 0 fingerprint) 且未传 `--bind-this-machine` →
303/// **loud reject** 不再 silent fall-through 到 None (None = "不启用绑定" =
304/// "无限制" silent inverse). 用户必须要么不传 `--bind-machines` 要么传至少
305/// 1 个真实指纹. `--freeze` 走 bind-key 独立路径 (允许显式空白名单).
306///
307/// v1.4.106 codex 0608 F5 (P3): fingerprint 解析改用 cmd::key_enums::
308/// parse_fingerprints_csv (与 bind-key 共用), 单一 source of truth.
309fn build_allowed_machines(
310    id: &str,
311    bind_this: bool,
312    bind_others: Option<&str>,
313) -> Result<Option<Vec<String>>> {
314    let mut list: Vec<String> = Vec::new();
315    if bind_this {
316        let fp = machine::fingerprint_for(id)
317            .map_err(|e| anyhow!("cannot compute this machine's fingerprint: {e}"))?;
318        list.push(fp);
319    }
320    if let Some(raw) = bind_others {
321        let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
322        if parsed.is_empty() && !bind_this {
323            // F4 (P2): 显式 --bind-machines ""/" ,," 但没配 --bind-this-machine →
324            // list 会留空 → 返 None → daemon 解 "无限制" → silent inverse.
325            // loud reject — 用户必须要么不传 --bind-machines 要么传至少 1 个 fp.
326            return Err(anyhow!(
327                "v1.4.106 F4: --bind-machines {raw:?} parsed to empty list \
328                 (no --bind-this-machine either). 这会让 allowed_machines = None \
329                 (无机器绑定限制) — 与你的意图相反. 如要 \"不启用绑定\" 请不传 \
330                 --bind-machines; 如要至少 1 个机器, 传至少 1 个 64-hex 指纹 \
331                 (`futucli machine-id --for-key <id>`)."
332            ));
333        }
334        list.extend(parsed);
335    }
336    if list.is_empty() {
337        return Ok(None);
338    }
339    // 去重保序
340    let mut seen = HashSet::new();
341    list.retain(|x| seen.insert(x.clone()));
342    Ok(Some(list))
343}
344
345struct KeyPrintView<'a> {
346    path: &'a Path,
347    id: &'a str,
348    plaintext: &'a str,
349    scopes: &'a HashSet<Scope>,
350    allowed_machines: Option<&'a [String]>,
351    max_orders_per_minute: Option<u32>,
352    allowed_trd_sides: Option<&'a [String]>,
353}
354
355fn print_result(view: KeyPrintView<'_>) {
356    let scope_list: Vec<&str> = view.scopes.iter().map(|s| s.as_str()).collect();
357    println!();
358    println!("=== FutuOpenD-rs API Key ===");
359    println!();
360    println!("  id     : {}", view.id);
361    println!("  scopes : {}", scope_list.join(", "));
362    println!("  path   : {}", view.path.display());
363    if let Some(r) = view.max_orders_per_minute {
364        println!("  rate   : {r} orders/min");
365    }
366    if let Some(sides) = view.allowed_trd_sides
367        && !sides.is_empty()
368    {
369        let mut v: Vec<String> = sides.to_vec();
370        v.sort();
371        println!("  sides  : {}", v.join(","));
372    }
373    if let Some(ms) = view.allowed_machines {
374        println!("  bound  : {} machine(s)", ms.len());
375        for fp in ms {
376            println!("           {}", &fp[..16]);
377        }
378    }
379    println!();
380    println!("Plaintext (shown once, SAVE IT NOW — file only stores SHA-256 hash):");
381    println!();
382    println!("  FUTU_MCP_API_KEY={}", view.plaintext);
383    println!();
384    println!("Add to your MCP client config, e.g. Claude Desktop claude_desktop_config.json:");
385    println!();
386    // v1.4.37+ UX:优先探测 futu-mcp 真实路径,fallback 给显眼警告占位符。
387    // 加拿大同事反馈:v1.4.36 之前的 "/abs/path/to/futu-mcp" 像真路径,容易误
388    // 粘贴。现在如果 futu-mcp 和 futucli 装在一起(Homebrew / cargo install /
389    // release tarball 几乎总是如此),模板里就是可用的绝对路径 —— 复制粘贴即用。
390    let (mcp_command, post_note) = match detect_futu_mcp_path() {
391        Some(p) => (
392            format!("\"{}\"", p.display()),
393            format!("(auto-detected: {})", p.display()),
394        ),
395        None => (
396            r#""REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp""#.to_string(),
397            "⚠️  futu-mcp not found in PATH or next to futucli — replace the \
398                command field above with the absolute path (run: `which futu-mcp`)."
399                .to_string(),
400        ),
401    };
402    println!("  {{");
403    println!("    \"mcpServers\": {{");
404    println!("      \"futu\": {{");
405    println!("        \"command\": {mcp_command},");
406    println!(
407        "        \"args\": [\"--keys-file\", \"{}\"],",
408        view.path.display()
409    );
410    println!(
411        "        \"env\": {{ \"FUTU_MCP_API_KEY\": \"{}\" }}",
412        view.plaintext
413    );
414    println!("      }}");
415    println!("    }}");
416    println!("  }}");
417    println!();
418    println!("  command path: {post_note}");
419    println!();
420}
421
422#[cfg(test)]
423mod tests;