nfrp-client 0.6.2

NFrp 客户端:注册代理、按需建立工作连接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
//! 动态代理的落盘(`[store]`)。
//!
//! # 解决什么问题
//!
//! 面板上点"添加代理"加出来的隧道**只活在内存里**:客户端一重启就没了。
//! 而这类隧道往往是用户临时开给别人用的,不一定记得回来补进配置文件。
//! 配了 `[store] path` 之后它们会落盘,重启自动恢复。
//!
//! # 只存"动态"的那些
//!
//! 这是本模块最关键的一条约定:落盘文件里**只放面板/API 加进来的代理**,
//! 配置文件中写的代理一个都不进去。
//!
//! 反过来(把整张表都写进去)看着更简单,但会立刻出问题:
//! 用户从配置文件里删掉一条隧道 → 重启后它又从 store 里复活了,
//! 而且用户翻遍配置文件也找不到它在哪。配置文件必须始终是"我写了什么"的
//! 唯一真相,store 只是补上动态那部分的记忆。
//!
//! # 文件格式
//!
//! ```json
//! { "version": 1, "proxies": [ { "name": "x", "type": "tcp", ... } ] }
//! ```
//!
//! `version` 是**给自己留的后路**:将来格式要变,能识别出旧文件并给出可读的
//! 提示,而不是让 `serde` 抛一句"missing field"。另外官方 frp 的 store 是
//! Go 的 `configmgmt` 序列化格式,与这里**不通用** —— 换实现时得重新加一遍,
//! README 里写明了。

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use anyhow::{Context, Result};
use nfrp_common::config::{ClientConfig, ProxyConfig};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};

/// 落盘文件的当前版本号。
const STORE_VERSION: u32 = 1;

/// 落盘文件的结构。
#[derive(Debug, Serialize, Deserialize)]
struct StoreFile {
    /// 格式版本。缺省按 1 处理(老文件没有这个字段也能读)。
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default)]
    proxies: Vec<ProxyConfig>,
}

fn default_version() -> u32 {
    1
}

/// 动态代理的持久化。
pub struct Store {
    /// 落盘路径。`None` = 不持久化(默认,与老版本完全一致)。
    path: Option<PathBuf>,
    /// 用 `BTreeMap` 而不是 `Vec`:文件里的顺序稳定,diff 好看,
    /// 测试也不用担心"顺序对不对"这种噪声。
    dynamic: Mutex<BTreeMap<String, ProxyConfig>>,
}

impl Store {
    /// 从配置构造,并把已有文件读进来。
    ///
    /// 读失败**不拦启动**:文件损坏或者格式不对时,最合理的做法是带着
    /// 一条响亮的告警继续跑(配置里的代理照常工作),而不是让整个客户端
    /// 起不来 —— 用户此刻可能正在外面用手机远程重启它。
    /// 但文件**不会**被立刻覆盖:只有下一次真的增删代理时才会重写。
    pub fn from_config(cfg: &ClientConfig) -> Result<Self> {
        let path = cfg.store.path.trim();
        if path.is_empty() {
            return Ok(Self {
                path: None,
                dynamic: Mutex::new(BTreeMap::new()),
            });
        }
        let path = PathBuf::from(path);
        let mut dynamic = BTreeMap::new();

        if path.exists() {
            match std::fs::read_to_string(&path) {
                Ok(raw) => match serde_json::from_str::<StoreFile>(&raw) {
                    Ok(f) => {
                        if f.version > STORE_VERSION {
                            warn!(
                                path = %path.display(),
                                file_version = f.version,
                                supported = STORE_VERSION,
                                "store 文件的版本比本程序新,已忽略其内容(不会覆盖它)"
                            );
                        } else {
                            for p in f.proxies {
                                if p.name.is_empty() {
                                    continue;
                                }
                                // ★★ v0.5.4 修 H3:store 里的内容**同样不可信**,
                                //    必须与 `ServerCmd` 路径跑同一道闸门。
                                //
                                // 为什么 store 也算远程来源:这个文件的用途就是
                                // "把服务端面板增删过的代理持久化下来"(见模块头注释),
                                // 所以里面完全可能出现服务端下发的字段。早先这里
                                // 只查 `name` 非空,于是:
                                //
                                //   有人在 store 里写入
                                //     {"plugin":"static_file","plugin_local_path":"/etc"}
                                //   重启后**直接生效** ⇒ 任意文件读取,
                                //   而且绕过了为拦截它专门写的那道 `validate_remote_proxy`
                                //   (威胁模型与第一轮修复 #2 完全相同,只是换了扇门进来)。
                                //
                                // 处理策略:**跳过该条目并 WARN**,而不是整份拒绝。
                                // 理由:store 里可能同时躺着别的合法代理,
                                // 一条脏数据不该让整个客户端起不来(用户此刻可能
                                // 正在外面用手机远程重启它,见上面 `from_config` 的注释)。
                                // 关键是**绝不能让它静默生效**。
                                if let Err(e) = nfrp_common::config::validate_remote_proxy(&p, true)
                                {
                                    warn!(
                                        path = %path.display(),
                                        proxy = %p.name,
                                        error = %e,
                                        "store 中的代理未通过安全校验,已跳过(不会进入运行态)"
                                    );
                                    continue;
                                }
                                dynamic.insert(p.name.clone(), p);
                            }
                        }
                    }
                    Err(e) => warn!(
                        path = %path.display(),
                        error = %e,
                        "store 文件解析失败,已忽略(下次增删代理时会用新内容覆盖)"
                    ),
                },
                Err(e) => warn!(path = %path.display(), error = %e, "读取 store 文件失败,已忽略"),
            }
        }

        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() && !parent.exists() {
                std::fs::create_dir_all(parent)
                    .with_context(|| format!("创建 store 目录 {} 失败", parent.display()))?;
            }
        }

        info!(
            path = %path.display(),
            restored = dynamic.len(),
            "客户端 Store 已启用:动态添加的代理会落盘并在重启后恢复"
        );

        Ok(Self {
            path: Some(path),
            dynamic: Mutex::new(dynamic),
        })
    }

    pub fn is_enabled(&self) -> bool {
        self.path.is_some()
    }

    pub fn path(&self) -> Option<&Path> {
        self.path.as_deref()
    }

    /// 文件里记着的动态代理(启动时并入代理表)。
    pub fn dynamic(&self) -> Vec<ProxyConfig> {
        self.lock().values().cloned().collect()
    }

    pub fn dynamic_names(&self) -> Vec<String> {
        self.lock().keys().cloned().collect()
    }

    /// 记下(或更新)一条动态代理并落盘。
    pub fn put(&self, p: &ProxyConfig) -> Result<()> {
        if self.path.is_none() {
            return Ok(());
        }
        self.lock().insert(p.name.clone(), p.clone());
        self.flush()
    }

    /// 忘掉一条动态代理并落盘。返回它原本是否在文件里。
    pub fn remove(&self, name: &str) -> Result<bool> {
        if self.path.is_none() {
            return Ok(false);
        }
        let existed = self.lock().remove(name).is_some();
        if existed {
            self.flush()?;
        }
        Ok(existed)
    }

    /// 原子写:先写 `<path>.tmp` 再 rename。
    ///
    /// 直接往目标文件里写,一旦在写一半时进程被 kill / 磁盘满,
    /// 留下的是**半截 JSON** —— 下次启动就解析失败,所有动态隧道一起丢。
    /// rename 在同一文件系统内是原子的,最坏情况只是丢掉这一次改动。
    fn flush(&self) -> Result<()> {
        let Some(path) = self.path.as_ref() else {
            return Ok(());
        };
        let file = StoreFile {
            version: STORE_VERSION,
            proxies: self.lock().values().cloned().collect(),
        };
        let body = serde_json::to_vec_pretty(&file).context("序列化 store 失败")?;

        let tmp = path.with_extension("tmp");
        // ★ 这份文件里装着**完整的代理配置**:`secret_key`、插件密码、
        // `http_pwd` 都在里面。`logfile.rs` 早就按 0600 落盘(注释里也写了
        // "日志里可能有 token"),这里一直漏了 —— 同机其他用户可直接读走密钥。
        //
        // 先删掉同名临时文件:若有人预先把 `xxx.tmp` 做成软链,直接 open
        // 会写到链接指向的地方(TOCTOU)。删一次把"跟着软链走"掐掉。
        let _ = std::fs::remove_file(&tmp);

        // Unix 上显式 0600;Windows 没有这个模式位(靠父目录 ACL),
        // 所以用 cfg 分开建 OpenOptions。
        #[cfg(unix)]
        let opt = {
            use std::os::unix::fs::OpenOptionsExt as _;
            let mut o = std::fs::OpenOptions::new();
            o.write(true).create_new(true).mode(0o600);
            o
        };
        // ★★ v0.5.6 修(审计总报告 M12,中危):**Windows 也要收紧权限**。
        //
        // 原先 Windows 分支只是 `create_new`,注释说"靠父目录 ACL"——
        // 这个假设**不成立**:文件默认继承父目录的继承 ACE,而用户目录
        // (`C:\Users\X`)的 ACL 里含 `Users`(所有本地用户)与
        // `Authenticated Users`。⇒ 同机**任何其他用户**都能读走这份
        // `secret_key` / 插件密码 / `http_pwd`。
        //
        // 修法:建好文件后用 `icacls` 把继承**断开**,只留当前用户
        // (`/inheritance:r` 清掉继承来的 ACE,`/grant:r` 只授当前用户)。
        //
        // ★ 为什么用 `icacls` 而不是 `SetSecurityInfo`:后者要引
        //   `windows-sys` 并手写 ACL 结构(几十行 unsafe),而 `icacls`
        //   是每台 Windows 都有的系统命令、语义明确、失败可见。
        //   这里是**低频**操作(只在 store 变化时落盘),起进程的开销可忽略。
        //
        // ★ 失败处理:**不让它阻断落盘**,但要 warn —— 落盘本身是功能,
        //   权限收紧是加固;因为加固失败就丢掉用户的动态代理更糟。
        #[cfg(windows)]
        let opt = {
            let mut o = std::fs::OpenOptions::new();
            o.write(true).create_new(true);
            o
        };
        #[cfg(all(not(unix), not(windows)))]
        let opt = {
            let mut o = std::fs::OpenOptions::new();
            o.write(true).create_new(true);
            o
        };
        {
            use std::io::Write as _;
            let mut f = opt
                .open(&tmp)
                .with_context(|| format!("写临时文件 {} 失败", tmp.display()))?;
            f.write_all(&body)
                .with_context(|| format!("写临时文件 {} 失败", tmp.display()))?;
            f.sync_all().ok();
        }

        // ★ v0.5.6(M12):Windows 上断开继承、只留当前用户。
        #[cfg(windows)]
        {
            let user = std::env::var("USERNAME").unwrap_or_default();
            if user.is_empty() {
                warn!(
                    path = %tmp.display(),
                    "拿不到 USERNAME,无法收紧 store 文件权限 —— \
                     同机其他用户可能读到其中的 secret_key,请手工检查该文件 ACL"
                );
            } else {
                match std::process::Command::new("icacls")
                    .arg(&tmp)
                    .arg("/inheritance:r")
                    .arg("/grant:r")
                    .arg(format!("{user}:F"))
                    .stdout(std::process::Stdio::null())
                    .stderr(std::process::Stdio::null())
                    .status()
                {
                    Ok(s) if s.success() => {
                        debug!(path = %tmp.display(), %user, "store 文件权限已收紧为仅当前用户");
                    }
                    Ok(s) => warn!(
                        path = %tmp.display(), code = ?s.code(),
                        "icacls 收紧 store 权限失败(非致命)—— 同机其他用户可能读到 secret_key"
                    ),
                    Err(e) => warn!(
                        path = %tmp.display(), error = %e,
                        "无法执行 icacls 收紧 store 权限(非致命)—— 请手工检查该文件 ACL"
                    ),
                }
            }
        }
        std::fs::rename(&tmp, path)
            .with_context(|| format!("把 {} 改名为 {} 失败", tmp.display(), path.display()))?;
        debug!(path = %path.display(), count = file.proxies.len(), "store 已落盘");
        Ok(())
    }

    /// 锁被 poison 时照用不误:那说明"曾有人持锁 panic 了",
    /// 不代表这份数据坏了 —— 跟着 panic 只会让动态代理全部失效。
    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, ProxyConfig>> {
        self.dynamic.lock().unwrap_or_else(|e| e.into_inner())
    }
}

impl std::fmt::Debug for Store {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Store")
            .field("path", &self.path)
            .field("dynamic", &self.dynamic_names())
            .finish()
    }
}

/// 把配置里的代理与 store 里的动态代理并成一张初始表。
///
/// **配置文件优先**:同名时以配置文件为准,并告警。
/// 理由是配置文件是用户"我看着它写下的"那份东西,必须说话算数;
/// 而且从配置文件里删掉一条隧道时,用户期待的是它真的消失
/// (如果让 store 优先,它会被 store 里的旧副本复活,且无处可查)。
///
/// ★ v0.5.4(H3 纵深防御):这里对 `stored` 再做一遍 `validate_remote_proxy`。
/// `Store::from_config` 才是主入口(已经拦了),但本函数是 public 的、
/// 也是"配置 + 动态"合流的唯一汇聚点 —— 在这儿再挡一次,
/// 保证**任何**未来新增的调用点都不会把未校验的条目送进运行态。
pub fn merge_initial(config: &[ProxyConfig], stored: Vec<ProxyConfig>) -> Vec<ProxyConfig> {
    let mut out: Vec<ProxyConfig> = config.to_vec();
    let mut seen: std::collections::HashSet<String> =
        config.iter().map(|p| p.name.clone()).collect();
    for p in stored {
        if seen.contains(&p.name) {
            warn!(
                proxy = %p.name,
                "store 里的动态代理与配置文件同名,按配置文件为准(store 里那条已忽略)"
            );
            continue;
        }
        // ★ 与 `ServerCmd` / 配置文件同口径的安全校验(store 内容不可信)
        if p.name.is_empty() {
            continue;
        }
        if let Err(e) = nfrp_common::config::validate_remote_proxy(&p, true) {
            warn!(
                proxy = %p.name,
                error = %e,
                "store 中的代理未通过安全校验,已跳过"
            );
            continue;
        }
        seen.insert(p.name.clone());
        out.push(p);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg_with_path(path: &Path) -> ClientConfig {
        let mut c = ClientConfig::default();
        c.store.path = path.to_string_lossy().to_string();
        c
    }

    fn proxy(name: &str) -> ProxyConfig {
        ProxyConfig {
            name: name.into(),
            proxy_type: "tcp".into(),
            local_addr: "127.0.0.1:80".into(),
            remote_port: 6000,
            ..Default::default()
        }
    }

    #[test]
    fn 默认不落盘() {
        let c = ClientConfig::default();
        let s = Store::from_config(&c).unwrap();
        assert!(!s.is_enabled());
        assert!(s.path().is_none());
        // 关着的时候增删都是空操作,也不该建任何文件
        s.put(&proxy("a")).unwrap();
        assert!(s.dynamic().is_empty());
    }

    #[test]
    fn 落盘并能在新进程里读回() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let path = dir.join("store.json");
        let c = cfg_with_path(&path);

        {
            let s = Store::from_config(&c).unwrap();
            assert!(s.is_enabled());
            s.put(&proxy("panel-web")).unwrap();
            s.put(&proxy("panel-db")).unwrap();
            assert_eq!(s.dynamic_names(), vec!["panel-db", "panel-web"]);
        }

        // 模拟重启:重新打开同一个路径
        let s2 = Store::from_config(&c).unwrap();
        let names = s2.dynamic_names();
        assert_eq!(names, vec!["panel-db", "panel-web"], "重启后必须能读回");

        // 删掉一条也要落盘
        assert!(s2.remove("panel-web").unwrap());
        let s3 = Store::from_config(&c).unwrap();
        assert_eq!(s3.dynamic_names(), vec!["panel-db"]);
        assert!(!s3.remove("panel-web").unwrap(), "重复删除返回 false");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn 文件损坏不拦启动() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-bad-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("store.json");
        std::fs::write(&path, "{ 这不是 JSON").unwrap();

        let c = cfg_with_path(&path);
        let s = Store::from_config(&c).expect("损坏的 store 不该拦住启动");
        assert!(s.dynamic().is_empty());
        // 而且**不能**因为读失败就把它覆盖掉 —— 用户可能还想去手工抢救
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ 这不是 JSON");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn 目录不存在会自动创建() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-mk-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let path = dir.join("deep").join("store.json");
        let c = cfg_with_path(&path);
        let s = Store::from_config(&c).unwrap();
        s.put(&proxy("a")).unwrap();
        assert!(path.exists(), "嵌套目录也要能自动建出来");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn 配置文件优先于_store() {
        let merged = merge_initial(
            &[proxy("web"), proxy("ssh")],
            vec![proxy("web"), proxy("tmp")],
        );
        let names: Vec<String> = merged.iter().map(|p| p.name.clone()).collect();
        assert_eq!(names, vec!["web", "ssh", "tmp"]);
        // web 只出现一次,且来自配置文件
        assert_eq!(merged.iter().filter(|p| p.name == "web").count(), 1);
    }

    // ------------------------------------------------------------------
    // H3(v0.5.4):store 恢复路径必须走 validate_remote_proxy
    // ------------------------------------------------------------------

    /// ★★ H3 核心回归:store 里的危险字段**必须在恢复时被拦下**。
    ///
    /// 威胁模型:store 文件的用途是持久化"服务端面板增删过的代理",
    /// 所以它的内容与服务端下发的 ServerCmd 属于**同一信任级别**。
    /// 早先这里只查 `name` 非空,于是一份写着
    /// `{"plugin":"static_file","plugin_local_path":"/etc"}` 的 store 文件
    /// 重启后直接生效 ⇒ 任意文件读取,且绕过了第一轮专门为此写的闸门。
    #[test]
    fn store_里的危险字段必须在恢复时被拦下() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-h3-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("store.json");

        // 手工构造一份含危险字段的 store(模拟被写入/被篡改)
        //
        // ★ 字段名必须是 **snake_case** —— `ProxyConfig` 的 serde 没加 rename。
        // ★ `local_addr` 是 ProxyConfig 的**必填字段**(没有 serde default):
        //   漏了它会让**整份文件**反序列化失败、落到"解析失败已忽略"分支,
        //   于是条目压根没进内存 —— 测试就会因为这个原因假通过,
        //   而不是因为校验真的拦住了它。我第一版正是这么写错的,
        //   靠"还原旧代码后测试仍绿"才发现(见 tmp/verify_h3_test.py)。
        let evil = r#"{
  "version": 1,
  "proxies": [
    {
      "name": "evil",
      "type": "tcp",
      "local_addr": "127.0.0.1:9999",
      "plugin": "static_file",
      "plugin_local_path": "/etc",
      "remote_port": 6001
    }
  ]
}"#;
        std::fs::write(&path, evil).unwrap();

        let c = cfg_with_path(&path);
        let s = Store::from_config(&c).unwrap();

        assert!(
            !s.dynamic_names().iter().any(|n| n == "evil"),
            "带 pluginLocalPath 的 store 条目绝不能进入运行态,实际:{:?}",
            s.dynamic_names()
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 四种危险字段逐个验证(不止 pluginLocalPath)。
    #[test]
    fn store_里四种本机资源字段都要拦() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-h3b-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let cases = [
            ("plugin_local_path", "\"plugin_local_path\": \"/etc\""),
            (
                "plugin_local_addr",
                "\"plugin_local_addr\": \"169.254.169.254:80\"",
            ),
            (
                "plugin_crt_path",
                "\"plugin_crt_path\": \"/etc/ssl/private/k.pem\"",
            ),
            (
                "plugin_key_path",
                "\"plugin_key_path\": \"/etc/ssl/private/k.key\"",
            ),
        ];

        for (field, line) in cases {
            let path = dir.join(format!("store-{field}.json"));
            // ★ 必须带齐必填字段(`local_addr`),否则整份文件解析失败 ⇒ 假通过
            let content = format!(
                "{{\"version\":1,\"proxies\":[{{\"name\":\"e\",\"type\":\"tcp\",\
                  \"local_addr\":\"127.0.0.1:9999\",{line}}}]}}"
            );
            std::fs::write(&path, content).unwrap();
            let c = cfg_with_path(&path);
            let s = Store::from_config(&c).unwrap();
            assert!(
                s.dynamic_names().is_empty(),
                "{field} 没被拦住:{:?}",
                s.dynamic_names()
            );
        }

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 干净的 store 仍要正常恢复 —— 修复不能误伤合法用法。
    #[test]
    fn store_里的合法代理仍能恢复() {
        let dir = std::env::temp_dir().join(format!("nfrp-store-h3c-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("store.json");

        // 注意:`ProxyConfig` 的 serde 字段名是 **snake_case**(没加 rename),
        // 所以这里写 `local_addr` / `remote_port` 而不是 camelCase。
        let good = r#"{
  "version": 1,
  "proxies": [
    {"name": "ok", "type": "tcp", "local_addr": "127.0.0.1:8080", "remote_port": 6002}
  ]
}"#;
        std::fs::write(&path, good).unwrap();

        let c = cfg_with_path(&path);
        let s = Store::from_config(&c).unwrap();
        assert!(
            s.dynamic_names().iter().any(|n| n == "ok"),
            "合法条目必须照常恢复,实际:{:?}",
            s.dynamic_names()
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// `merge_initial` 是合流点,也要挡住危险条目(纵深防御)。
    #[test]
    fn merge_initial_也要挡住危险条目() {
        let mut bad = proxy("bad");
        bad.plugin_local_path = "/etc".into();

        let merged = merge_initial(&[proxy("good")], vec![bad]);
        let names: Vec<String> = merged.iter().map(|p| p.name.clone()).collect();
        assert_eq!(names, vec!["good"], "危险条目不能通过 merge_initial 混进来");
    }
}