Skip to main content

futucli/cmd/
bind_key.rs

1//! `futucli bind-key`: 就地编辑已存在 key 的 `allowed_machines`
2//!
3//! 不想走 `revoke` + `gen-key` 因为那会换 plaintext —— 客户端都得重新配。
4//! 这里支持追加 / 替换 / 清除 / 冻结四种动作:
5//!
6//! | 动作     | 语义                                     | flag                              |
7//! |---------|------------------------------------------|-----------------------------------|
8//! | 追加    | 把指纹加进现有白名单(去重,默认行为)   | `--this-machine` / `--machines X` |
9//! | 替换    | 用新指纹覆盖整个白名单                   | `--replace`(配合上面任一)        |
10//! | 清除    | 把 `allowed_machines` 置 None(解绑)    | `--clear`                         |
11//! | 冻结    | 把 `allowed_machines` 置 `[]` 拒绝所有   | `--freeze`                        |
12//!
13//! 记得 `--clear` / `--freeze` / `--replace` 三者互斥。改完后要让运行中的
14//! 网关生效:`kill -HUP $(pgrep futu-opend)`(MCP 同理)。
15
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19use anyhow::{Context, Result, anyhow};
20use futu_auth::{machine, store};
21
22fn default_keys_path() -> Result<PathBuf> {
23    let base =
24        dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
25    Ok(base.join("futu").join("keys.json"))
26}
27
28// v1.4.106 codex 0608 F5 (P3): parse_fingerprints 移到 cmd::key_enums 模块
29// 与 gen-key 共用 (单一 source of truth). Re-export 给 cfg(test) 老 test
30// 名称兼容 — 走的是 cmd::key_enums::parse_fingerprints_csv 同实现.
31#[cfg(test)]
32fn parse_fingerprints(raw: &str) -> Result<Vec<String>> {
33    crate::cmd::key_enums::parse_fingerprints_csv(raw)
34}
35
36pub struct BindKeyCommand {
37    pub id: String,
38    pub keys_file: Option<PathBuf>,
39    pub this_machine: bool,
40    pub machines: Option<String>,
41    pub replace: bool,
42    pub clear: bool,
43    pub freeze: bool,
44}
45
46pub async fn run(input: BindKeyCommand) -> Result<()> {
47    let BindKeyCommand {
48        id,
49        keys_file,
50        this_machine,
51        machines,
52        replace,
53        clear,
54        freeze,
55    } = input;
56
57    // 互斥性校验
58    let mutating_ops = [clear, freeze, replace].iter().filter(|x| **x).count();
59    if mutating_ops > 1 {
60        return Err(anyhow!(
61            "--clear / --freeze / --replace are mutually exclusive"
62        ));
63    }
64    if clear && (this_machine || machines.is_some()) {
65        return Err(anyhow!(
66            "--clear does not take --this-machine / --machines (it removes the binding entirely)"
67        ));
68    }
69    if freeze && (this_machine || machines.is_some()) {
70        return Err(anyhow!(
71            "--freeze does not take --this-machine / --machines (it sets allowed_machines = [])"
72        ));
73    }
74    if !clear && !freeze && !this_machine && machines.is_none() {
75        return Err(anyhow!(
76            "nothing to do — pass --this-machine / --machines / --clear / --freeze"
77        ));
78    }
79
80    let path = match keys_file {
81        Some(p) => p,
82        None => default_keys_path()?,
83    };
84
85    // 预先算好要加的指纹(失败早 fail,不要修了一半才报错)
86    //
87    // v1.4.106 codex 0608 F4 (P2): `--machines ""` / `--machines ", ,"` 显式
88    // 传空 CSV (parse 后 0 fingerprint) 且未传 `--this-machine` →
89    // **loud reject** (替代旧 silent "no change (already in desired state)"
90    // 反馈, 让用户立刻看到错误意图). `--freeze` 独立路径 (允许显式空白名单)
91    // — 上面已分支判断 freeze && machines.is_some() 互斥拦截.
92    let mut new_fps: Vec<String> = Vec::new();
93    if this_machine {
94        let fp = machine::fingerprint_for(&id)
95            .map_err(|e| anyhow!("compute this machine's fingerprint: {e}"))?;
96        new_fps.push(fp);
97    }
98    if let Some(raw) = machines.as_deref() {
99        let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
100        if parsed.is_empty() && !this_machine {
101            return Err(anyhow!(
102                "v1.4.106 F4: --machines {raw:?} parsed to empty list \
103                 (no --this-machine either). 这是 silent no-op — 与你的意图相反. \
104                 如要 \"清除绑定\" 用 --clear; 如要 \"冻结所有\" 用 --freeze; \
105                 如要追加机器, 传至少 1 个 64-hex 指纹."
106            ));
107        }
108        new_fps.extend(parsed);
109    }
110
111    let changed = store::update_key(&path, &id, |rec| {
112        if clear {
113            if rec.allowed_machines.is_none() {
114                return false;
115            }
116            rec.allowed_machines = None;
117            return true;
118        }
119        if freeze {
120            if matches!(rec.allowed_machines.as_deref(), Some(list) if list.is_empty()) {
121                return false;
122            }
123            rec.allowed_machines = Some(Vec::new());
124            return true;
125        }
126        if replace {
127            rec.allowed_machines = Some(new_fps.clone());
128            return true;
129        }
130        // 默认:追加 + 去重
131        let mut existing = rec.allowed_machines.clone().unwrap_or_default();
132        let mut seen: HashSet<String> = existing.iter().cloned().collect();
133        let mut added_any = false;
134        for fp in &new_fps {
135            if seen.insert(fp.clone()) {
136                existing.push(fp.clone());
137                added_any = true;
138            }
139        }
140        if !added_any && rec.allowed_machines.is_some() {
141            return false;
142        }
143        rec.allowed_machines = Some(existing);
144        true
145    })
146    .with_context(|| format!("update key in {}", path.display()))?;
147
148    if !changed {
149        // 两种情况:key 不存在;或者确实没改动
150        let ids = store::list_keys(&path)
151            .with_context(|| format!("list keys after no-op update in {}", path.display()))?;
152        if !ids.iter().any(|k| k.id == id) {
153            return Err(anyhow!("no key with id={id:?} in {}", path.display()));
154        }
155        println!("no change (already in desired state): id={id:?}");
156        return Ok(());
157    }
158
159    // 打印结果
160    let rec = store::list_keys(&path)
161        .context("re-read after update")?
162        .into_iter()
163        .find(|k| k.id == id)
164        .ok_or_else(|| anyhow!("key disappeared after update (race?)"))?;
165
166    println!("updated: id={id:?}  ({})", path.display());
167    match rec.allowed_machines.as_deref() {
168        None => println!("  bound : (none — unbound, any machine allowed)"),
169        Some([]) => println!("  bound : [] (FROZEN — no machine allowed)"),
170        Some(list) => {
171            println!("  bound : {} machine(s)", list.len());
172            for fp in list {
173                println!("          {}", &fp[..16]);
174            }
175        }
176    }
177    println!();
178    println!("note: running gateway must receive SIGHUP to pick up the change. Run:");
179    println!("  kill -HUP $(pgrep futu-opend)    # Linux / macOS,shell 会自动探测 pid");
180    println!(
181        "  # Windows / 没装 pgrep:先 `ps aux | grep futu-opend` 查 pid,再 `kill -HUP <pid>`"
182    );
183    Ok(())
184}
185
186#[cfg(test)]
187mod tests;