Skip to main content

futucli/cmd/
unlock.rs

1//! `futucli unlock-trade` — 解锁 / 锁回交易
2//!
3//! 对 gateway 执行一次 UnlockTrade。成功后 gateway 进程级缓存会持有 cipher,
4//! 后续所有客户端(futucli / futu-mcp / Python)的下单都能自动拿到 cipher,
5//! **直到 gateway 重启**。
6//!
7//! 密码来源优先级:
8//! 1. `--from-stdin`:从 stdin 读一整行(脚本友好)
9//! 2. 环境变量 `FUTU_TRADE_PWD`
10//! 3. 账号级 OS keychain(`futucli set-trade-pwd --account <login-account>`)
11//! 4. legacy OS keychain(v1.4.109 前全局 `trade-password` 条目)
12//! 5. 交互式 tty prompt(无回显)
13//!
14//! 明文密码不会出现在命令行参数里,避免 shell history / `/proc/*/cmdline` 泄露。
15//! MD5 在本地计算后再发送。
16
17use std::io::{self, BufRead};
18
19use anyhow::{Context, Result, bail};
20use serde::Serialize;
21
22use crate::common::connect_gateway;
23
24/// CLI 端的 SecurityFirm 枚举:clap ValueEnum 同时接受官方名称(FutuHK / FutuUS)
25/// 和短别名(hk / us)。值映射到 proto `Trd_Common.SecurityFirm` int32。
26#[derive(Debug, Clone, Copy, clap::ValueEnum)]
27#[non_exhaustive]
28pub enum SecurityFirmArg {
29    #[value(name = "FutuHK", alias = "hk", alias = "futu-hk", alias = "1")]
30    FutuHK,
31    #[value(
32        name = "FutuUS",
33        alias = "us",
34        alias = "futu-us",
35        alias = "2",
36        alias = "moomoo",
37        alias = "mm"
38    )]
39    FutuUS,
40    #[value(name = "FutuSG", alias = "sg", alias = "futu-sg", alias = "3")]
41    FutuSG,
42    #[value(name = "FutuAU", alias = "au", alias = "futu-au", alias = "4")]
43    FutuAU,
44    #[value(name = "FutuCA", alias = "ca", alias = "futu-ca", alias = "5")]
45    FutuCA,
46    #[value(name = "FutuMY", alias = "my", alias = "futu-my", alias = "6")]
47    FutuMY,
48    #[value(name = "FutuJP", alias = "jp", alias = "futu-jp", alias = "7")]
49    FutuJP,
50}
51
52impl SecurityFirmArg {
53    pub fn as_i32(self) -> i32 {
54        match self {
55            Self::FutuHK => 1,
56            Self::FutuUS => 2,
57            Self::FutuSG => 3,
58            Self::FutuAU => 4,
59            Self::FutuCA => 5,
60            Self::FutuMY => 6,
61            Self::FutuJP => 7,
62        }
63    }
64}
65
66pub async fn run(
67    gateway: &str,
68    lock: bool,
69    from_stdin: bool,
70    trade_pwd_account: Option<&str>,
71    otp: Option<String>,
72    security_firm: Option<SecurityFirmArg>,
73    // v1.4.34: 只解锁这些 acc_ids(空 = 不 per-account filter)。和 security_firm
74    // 同时传时是交集。解决同 broker 内影子账户拖垮主账户的场景。
75    acc_ids: Vec<u64>,
76    // v1.4.98 external reviewer BUG-005 fix (P2, 2026-04-27): 加 format param 让 lock/unlock
77    // 路径都能 honor `-o json` (脚本/agent 用).
78    format: crate::output::OutputFormat,
79) -> Result<()> {
80    let (client, _push_rx) = connect_gateway(gateway, "futucli-unlock").await?;
81
82    if lock {
83        // lock 时 pwd_md5 不参与校验,传空串即可
84        futu_trd::account::unlock_trade(
85            &client,
86            "",
87            false,
88            None,
89            security_firm.map(|s| s.as_i32()),
90            acc_ids,
91        )
92        .await
93        .context("lock trade failed")?;
94        match format {
95            crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl => {
96                let outcome = futu_trd::account::UnlockTradeOutcome {
97                    total_requested: 0,
98                    total_unlocked: 0,
99                    need_otp: false,
100                    failed_accounts: vec![],
101                    message: None,
102                };
103                println!(
104                    "{}",
105                    render_unlock_trade_output(format, "lock", gateway, &outcome)?
106                );
107            }
108            _ => {
109                println!("Trade locked on gateway {gateway}.");
110            }
111        }
112        return Ok(());
113    }
114
115    let pwd = read_password(from_stdin, trade_pwd_account)?;
116    if pwd.is_empty() {
117        bail!("empty password");
118    }
119    let pwd_md5 = format!("{:x}", md5::compute(pwd.as_bytes()));
120
121    let outcome = futu_trd::account::unlock_trade(
122        &client,
123        &pwd_md5,
124        true,
125        otp.as_deref(),
126        security_firm.map(|s| s.as_i32()),
127        acc_ids,
128    )
129    .await
130    .context("unlock trade failed")?;
131
132    // v1.4.31: 显示 per-broker per-account 结果
133    if matches!(
134        format,
135        crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
136    ) {
137        println!(
138            "{}",
139            render_unlock_trade_output(format, "unlock", gateway, &outcome)?
140        );
141        return Ok(());
142    }
143
144    if outcome.need_otp {
145        println!(
146            "⚠️  服务端要求 OTP / 令牌动态密码。失败账户:{:?}",
147            outcome.failed_accounts
148        );
149        println!(
150            "  重试:`futucli unlock-trade --otp REPLACE_WITH_6DIGIT_OTP`(保留相同密码来源)"
151        );
152        println!(
153            "  ⚠️  把 `REPLACE_WITH_6DIGIT_OTP` 换成富途令牌 app 里当前显示的 6 位动态密码,别原样粘贴"
154        );
155        return Ok(());
156    }
157    println!(
158        "Trade unlock: {}/{} accounts unlocked.",
159        outcome.total_unlocked, outcome.total_requested
160    );
161    if outcome.total_unlocked < outcome.total_requested {
162        println!(
163            "⚠️  失败账户(常见原因:该账户品种权限未开通 / 影子子账户):{:?}",
164            outcome.failed_accounts
165        );
166        if let Some(msg) = &outcome.message {
167            println!("  daemon 信息:{msg}");
168        }
169    }
170    println!("Cipher is cached in the gateway process; will expire when gateway restarts.");
171    Ok(())
172}
173
174#[derive(Serialize)]
175struct UnlockTradeCliOutput<'a> {
176    ok: bool,
177    action: &'a str,
178    gateway: &'a str,
179    total_requested: usize,
180    total_unlocked: usize,
181    need_otp: bool,
182    failed_accounts: &'a [u64],
183    #[serde(skip_serializing_if = "Option::is_none")]
184    message: Option<&'a str>,
185    cipher_cached: bool,
186}
187
188fn render_unlock_trade_output(
189    format: crate::output::OutputFormat,
190    action: &str,
191    gateway: &str,
192    outcome: &futu_trd::account::UnlockTradeOutcome,
193) -> Result<String> {
194    let output = UnlockTradeCliOutput {
195        ok: !outcome.need_otp && outcome.total_unlocked == outcome.total_requested,
196        action,
197        gateway,
198        total_requested: outcome.total_requested,
199        total_unlocked: outcome.total_unlocked,
200        need_otp: outcome.need_otp,
201        failed_accounts: &outcome.failed_accounts,
202        message: outcome.message.as_deref(),
203        cipher_cached: action == "unlock" && !outcome.need_otp && outcome.total_unlocked > 0,
204    };
205
206    match format {
207        crate::output::OutputFormat::Json => {
208            serde_json::to_string_pretty(&output).map_err(Into::into)
209        }
210        crate::output::OutputFormat::Jsonl => serde_json::to_string(&output).map_err(Into::into),
211        crate::output::OutputFormat::Table | crate::output::OutputFormat::Markdown => Ok(format!(
212            "Trade {action}: {}/{} accounts unlocked.",
213            outcome.total_unlocked, outcome.total_requested
214        )),
215    }
216}
217
218fn read_password(from_stdin: bool, trade_pwd_account: Option<&str>) -> Result<String> {
219    if from_stdin {
220        let mut line = String::new();
221        io::stdin()
222            .lock()
223            .read_line(&mut line)
224            .context("read password from stdin")?;
225        return Ok(trim_stdin_password_line(&line));
226    }
227
228    let trade_pwd_account_env = std::env::var("FUTU_TRADE_PWD_ACCOUNT").ok();
229    let futu_account_env = std::env::var("FUTU_ACCOUNT").ok();
230    let env_pwd = std::env::var("FUTU_TRADE_PWD").ok();
231
232    if let Some(p) = trade_password_from_sources(
233        trade_pwd_account,
234        trade_pwd_account_env.as_deref(),
235        futu_account_env.as_deref(),
236        env_pwd.as_deref(),
237        read_keyring_password_entry,
238    ) {
239        return Ok(p);
240    }
241
242    rpassword::prompt_password("Trade password: ").context("read password from tty")
243}
244
245fn non_empty_trimmed(s: &str) -> Option<String> {
246    let s = s.trim();
247    (!s.is_empty()).then(|| s.to_string())
248}
249
250fn trade_pwd_account_from(
251    explicit: Option<&str>,
252    trade_pwd_account_env: Option<&str>,
253    futu_account_env: Option<&str>,
254) -> Option<String> {
255    explicit
256        .and_then(non_empty_trimmed)
257        .or_else(|| trade_pwd_account_env.and_then(non_empty_trimmed))
258        .or_else(|| futu_account_env.and_then(non_empty_trimmed))
259}
260
261fn read_keyring_password_entry(username: &str) -> Option<String> {
262    if let Ok(entry) = keyring::Entry::new(futu_auth::KEYRING_SERVICE, username)
263        && let Ok(pwd) = entry.get_password()
264        && !pwd.is_empty()
265    {
266        return Some(pwd);
267    }
268    None
269}
270
271fn trade_password_from_sources(
272    account_hint: Option<&str>,
273    trade_pwd_account_env: Option<&str>,
274    futu_account_env: Option<&str>,
275    env_pwd: Option<&str>,
276    mut read_keyring: impl FnMut(&str) -> Option<String>,
277) -> Option<String> {
278    if let Some(pwd) = env_pwd.and_then(non_empty_trimmed) {
279        return Some(pwd);
280    }
281
282    if let Some(account) =
283        trade_pwd_account_from(account_hint, trade_pwd_account_env, futu_account_env)
284    {
285        let scoped_username = futu_auth::keyring_username_for_trade_pwd(&account);
286        if let Some(pwd) = read_keyring(&scoped_username) {
287            return Some(pwd);
288        }
289    }
290
291    read_keyring(futu_auth::KEYRING_USERNAME_TRADE_PWD)
292}
293
294fn trim_stdin_password_line(line: &str) -> String {
295    line.trim_end_matches(['\n', '\r']).to_string()
296}
297
298fn read_keychain_password(kind: &str, from_stdin: bool) -> Result<String> {
299    if from_stdin {
300        let mut line = String::new();
301        io::stdin()
302            .lock()
303            .read_line(&mut line)
304            .with_context(|| format!("read {kind} password from stdin"))?;
305        let password = trim_stdin_password_line(&line);
306        if password.is_empty() {
307            bail!("empty password");
308        }
309        return Ok(password);
310    }
311
312    let pwd1 = rpassword::prompt_password(format!("{kind} password: "))
313        .context("read password from tty")?;
314    if pwd1.is_empty() {
315        bail!("empty password");
316    }
317    let pwd2 = rpassword::prompt_password("Confirm password: ").context("read confirm from tty")?;
318    if pwd1 != pwd2 {
319        bail!("passwords do not match");
320    }
321    Ok(pwd1)
322}
323
324/// `futucli set-trade-pwd --account <id>` —— 把交易密码写入 OS keychain。
325///
326/// 每个登录账号一条独立条目(username = `trade-password.<account>`),避免
327/// 多账号互相覆盖。`futucli unlock-trade` / `futu-mcp` 读取时通过
328/// `--trade-pwd-account` / `FUTU_TRADE_PWD_ACCOUNT` 选择对应条目。
329pub async fn set_trade_pwd(account: &str, from_stdin: bool) -> Result<()> {
330    let account = account.trim();
331    if account.is_empty() {
332        bail!("--account is required");
333    }
334    let pwd = read_keychain_password("Trade", from_stdin)?;
335    let username = futu_auth::keyring_username_for_trade_pwd(account);
336    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
337        .context("create keyring entry")?;
338    entry
339        .set_password(&pwd)
340        .context("write password to OS keychain")?;
341    println!(
342        "✓ trade password saved to OS keychain (service={}, account={})",
343        futu_auth::KEYRING_SERVICE,
344        username
345    );
346    println!(
347        "  futucli unlock-trade / futu-mcp read it with --trade-pwd-account {account} \
348         (or FUTU_TRADE_PWD_ACCOUNT={account})."
349    );
350    Ok(())
351}
352
353/// `futucli clear-trade-pwd --account <id>` —— 从 OS keychain 删除某账号的交易密码。
354pub async fn clear_trade_pwd(account: &str) -> Result<()> {
355    let account = account.trim();
356    if account.is_empty() {
357        bail!("--account is required");
358    }
359    let username = futu_auth::keyring_username_for_trade_pwd(account);
360    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
361        .context("create keyring entry")?;
362    match entry.delete_credential() {
363        Ok(()) => println!("✓ trade password removed from OS keychain (account={account})"),
364        Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
365        Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
366    }
367    Ok(())
368}
369
370/// `futucli set-login-pwd --account <id>` —— 把登录密码写入 OS keychain(v1.4.18+)。
371///
372/// 每个账号一条独立条目(username = `login-password.<account>`),避免多账号
373/// 互相覆盖。`futu-opend` 启动时如果没传 `--login-pwd` / `FUTU_PWD`,会从
374/// 这里读取对应 account 的密码。
375///
376/// 这是推荐的密码存储方式 —— 明文不进 `ps aux` / `~/.bash_history` /
377/// 配置文件 backup。
378pub async fn set_login_pwd(account: &str, from_stdin: bool) -> Result<()> {
379    if account.is_empty() {
380        bail!("--account is required");
381    }
382    let pwd = read_keychain_password("Login", from_stdin)?;
383    let username = futu_auth::keyring_username_for_login_pwd(account);
384    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
385        .context("create keyring entry")?;
386    entry
387        .set_password(&pwd)
388        .context("write password to OS keychain")?;
389    println!(
390        "✓ login password saved to OS keychain (service={}, account={})",
391        futu_auth::KEYRING_SERVICE,
392        username
393    );
394    println!("  futu-opend will read it automatically when --login-pwd / FUTU_PWD is not set.");
395    Ok(())
396}
397
398/// `futucli clear-login-pwd --account <id>` —— 从 OS keychain 删除某账号的登录密码。
399pub async fn clear_login_pwd(account: &str) -> Result<()> {
400    if account.is_empty() {
401        bail!("--account is required");
402    }
403    let username = futu_auth::keyring_username_for_login_pwd(account);
404    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
405        .context("create keyring entry")?;
406    match entry.delete_credential() {
407        Ok(()) => println!("✓ login password removed from OS keychain (account={account})"),
408        Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
409        Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests;