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 {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
proxies: Vec<ProxyConfig>,
}
fn default_version() -> u32 {
1
}
pub struct Store {
path: Option<PathBuf>,
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;
}
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)
}
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");
let _ = std::fs::remove_file(&tmp);
#[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
};
#[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();
}
#[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(())
}
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()
}
}
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;
}
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"]);
assert_eq!(merged.iter().filter(|p| p.name == "web").count(), 1);
}
#[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");
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);
}
#[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"));
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);
}
#[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");
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);
}
#[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 混进来");
}
}