feat: 为 EasyTier 添加完整 TOML 配置支持

- 新增快速配置与完整配置模式,兼容旧版配置
 - 统一扩展配置校验及受保护临时文件生命周期
- 支持通过 easytier-core -c 加载 TOML 配置
- 复用前端配置模式编辑组件并更新中英文文案与类型
- 补充配置校验、启动参数、文件权限及清理测试
This commit is contained in:
mofeng-git
2026-07-30 21:26:00 +08:00
parent 8b09824e04
commit ce1712ff2e
11 changed files with 656 additions and 290 deletions

View File

@@ -1,15 +1,15 @@
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
use toml_edit::DocumentMut;
use super::protected_config::ProtectedConfigFile;
use super::types::*;
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config};
use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200;
@@ -27,12 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
struct ExtensionProcess {
child: Child,
logs: Arc<RwLock<VecDeque<String>>>,
_temp_dir: Option<TempDir>,
_config_file: Option<ProtectedConfigFile>,
}
struct ExtensionLaunch {
args: Vec<String>,
temp_dir: Option<TempDir>,
config_file: Option<ProtectedConfigFile>,
}
pub struct ExtensionManager {
@@ -83,24 +83,12 @@ impl ExtensionManager {
match id {
ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => {
config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
}
ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty()
}
ExtensionId::Frpc => {
config.frpc.enabled
&& match config.frpc.config_mode {
FrpcConfigMode::Quick => {
!config.frpc.proxy_name.trim().is_empty()
&& !config.frpc.server_addr.trim().is_empty()
&& !config.frpc.token.is_empty()
}
FrpcConfigMode::Full => !config.frpc.custom_toml.trim().is_empty(),
}
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
}
ExtensionId::Frpc => config.frpc.enabled && validate_frpc_config(&config.frpc).is_ok(),
}
}
@@ -203,7 +191,7 @@ impl ExtensionManager {
ExtensionProcess {
child,
logs,
_temp_dir: launch.temp_dir,
_config_file: launch.config_file,
},
);
drop(processes);
@@ -286,12 +274,7 @@ impl ExtensionManager {
ExtensionId::Gostc => {
let c = &config.gostc;
if c.addr.trim().is_empty() {
return Err("GOSTC server address is required".into());
}
if c.key.is_empty() {
return Err("GOSTC client key is required".into());
}
validate_gostc_config(c)?;
let mut args = Vec::new();
@@ -307,35 +290,7 @@ impl ExtensionManager {
}
ExtensionId::Easytier => {
let c = &config.easytier;
if c.network_name.is_empty() {
return Err("EasyTier network name is required".into());
}
let mut args = vec![
"--network-name".to_string(),
c.network_name.clone(),
"--network-secret".to_string(),
c.network_secret.clone(),
];
for peer in &c.peer_urls {
if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]);
}
}
if let Some(ref ip) = c.virtual_ip {
if !ip.is_empty() {
args.extend(["-i".to_string(), ip.clone()]);
} else {
args.push("-d".to_string());
}
} else {
args.push("-d".to_string());
}
args
return Self::build_easytier_launch(&config.easytier).await;
}
ExtensionId::Frpc => {
@@ -345,58 +300,78 @@ impl ExtensionManager {
Ok(ExtensionLaunch {
args,
temp_dir: None,
config_file: None,
})
}
async fn build_easytier_launch(config: &EasytierConfig) -> Result<ExtensionLaunch, String> {
validate_easytier_config(config)?;
match config.config_mode {
EasytierConfigMode::Quick => Ok(ExtensionLaunch {
args: Self::build_easytier_quick_args(config),
config_file: None,
}),
EasytierConfigMode::Full => {
let config_file = ProtectedConfigFile::create(
"EasyTier",
"easytier.toml",
config.custom_toml.as_str(),
)
.await?;
Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
config_file: Some(config_file),
})
}
}
}
fn build_easytier_quick_args(config: &EasytierConfig) -> Vec<String> {
let mut args = vec![
"--network-name".to_string(),
config.network_name.clone(),
"--network-secret".to_string(),
config.network_secret.clone(),
];
for peer in &config.peer_urls {
if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]);
}
}
if let Some(ref ip) = config.virtual_ip {
if !ip.is_empty() {
args.extend(["-i".to_string(), ip.clone()]);
} else {
args.push("-d".to_string());
}
} else {
args.push("-d".to_string());
}
args
}
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
validate_frpc_config(config)?;
let config_text = match config.config_mode {
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?,
FrpcConfigMode::Full => Self::validate_frpc_full_toml(config)?.to_string(),
FrpcConfigMode::Full => config.custom_toml.clone(),
};
let temp_dir =
tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?;
let config_path = temp_dir.path().join("frpc.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Failed to protect FRPC config dir: {}", e))?;
}
tokio::fs::write(&config_path, config_text)
.await
.map_err(|e| format!("Failed to write FRPC config: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|e| format!("Failed to protect FRPC config: {}", e))?;
}
let config_file =
ProtectedConfigFile::create("FRPC", "frpc.toml", config_text.as_str()).await?;
Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(&config_path)],
temp_dir: Some(temp_dir),
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
config_file: Some(config_file),
})
}
fn validate_frpc_full_toml(config: &FrpcConfig) -> Result<&str, String> {
let trimmed = config.custom_toml.trim();
if trimmed.is_empty() {
return Err("FRPC full configuration is required".into());
}
trimmed
.parse::<DocumentMut>()
.map_err(|e| format!("FRPC full configuration is not valid TOML: {}", e))?;
Ok(config.custom_toml.as_str())
}
fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
if config.proxy_name.trim().is_empty() {
return Err("FRPC proxy name is required".into());
@@ -480,7 +455,7 @@ impl ExtensionManager {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn path_to_arg(path: &PathBuf) -> String {
fn path_to_arg(path: &Path) -> String {
path.to_string_lossy().to_string()
}
@@ -603,3 +578,107 @@ impl ExtensionManager {
futures::future::join_all(stop_futures).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn easytier_launch_revalidates_full_configuration() {
let config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
custom_toml: "instance_name = [".to_string(),
..Default::default()
};
let error = ExtensionManager::build_easytier_launch(&config)
.await
.err()
.expect("invalid full configuration should fail launch validation");
assert!(error.starts_with("EasyTier full configuration is not valid TOML:"));
}
#[test]
fn easytier_quick_mode_keeps_command_line_arguments() {
let config = EasytierConfig {
network_name: "one-kvm".to_string(),
network_secret: "secret".to_string(),
peer_urls: vec![
"tcp://peer-one:11010".to_string(),
String::new(),
"udp://peer-two:11010".to_string(),
],
virtual_ip: Some("10.20.30.40/24".to_string()),
..Default::default()
};
assert_eq!(
ExtensionManager::build_easytier_quick_args(&config),
vec![
"--network-name",
"one-kvm",
"--network-secret",
"secret",
"--peers",
"tcp://peer-one:11010",
"--peers",
"udp://peer-two:11010",
"-i",
"10.20.30.40/24",
]
);
}
#[tokio::test]
async fn easytier_full_mode_uses_protected_temporary_config() {
let config_text = "instance_name = \"one-kvm\"\n";
let config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
network_name: "ignored-quick-network".to_string(),
custom_toml: config_text.to_string(),
..Default::default()
};
let launch = ExtensionManager::build_easytier_launch(&config)
.await
.expect("full EasyTier launch should build");
assert_eq!(launch.args[0], "-c");
let config_path = std::path::PathBuf::from(&launch.args[1]);
assert_eq!(
config_path.file_name().and_then(|name| name.to_str()),
Some("easytier.toml")
);
assert_eq!(
tokio::fs::read_to_string(&config_path).await.unwrap(),
config_text
);
drop(launch);
assert!(!config_path.exists());
}
#[test]
fn easytier_auto_start_uses_fields_for_selected_mode() {
let mut config = ExtensionsConfig::default();
config.easytier.enabled = true;
config.easytier.network_name = "quick-network".to_string();
assert!(ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
config.easytier.config_mode = EasytierConfigMode::Full;
assert!(!ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
config.easytier.network_name.clear();
config.easytier.custom_toml = "instance_name = \"one-kvm\"".to_string();
assert!(ExtensionManager::is_enabled_for_config(
ExtensionId::Easytier,
&config
));
}
}

View File

@@ -1,6 +1,8 @@
mod manager;
mod protected_config;
mod software;
mod types;
mod validation;
pub use manager::ExtensionManager;
#[cfg(unix)]
@@ -8,3 +10,7 @@ pub use manager::TTYD_SOCKET_PATH;
#[cfg(windows)]
pub use manager::TTYD_TCP_ADDR;
pub use types::*;
pub(crate) use validation::{
validate_easytier_config, validate_extension_config, validate_frpc_config,
validate_gostc_config,
};

View File

@@ -0,0 +1,95 @@
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub(crate) struct ProtectedConfigFile {
_temp_dir: TempDir,
path: PathBuf,
}
impl ProtectedConfigFile {
pub(crate) async fn create(
extension_name: &str,
file_name: &str,
contents: &str,
) -> Result<Self, String> {
let temp_dir = tempfile::tempdir().map_err(|error| {
format!("Failed to create {} config dir: {}", extension_name, error)
})?;
let path = temp_dir.path().join(file_name);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
.map_err(|error| {
format!("Failed to protect {} config dir: {}", extension_name, error)
})?;
}
tokio::fs::write(&path, contents)
.await
.map_err(|error| format!("Failed to write {} config: {}", extension_name, error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|error| {
format!("Failed to protect {} config: {}", extension_name, error)
})?;
}
Ok(Self {
_temp_dir: temp_dir,
path,
})
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn protects_and_cleans_up_config_file() {
let config =
ProtectedConfigFile::create("Test extension", "extension.toml", "enabled = true\n")
.await
.unwrap();
let path = config.path().to_path_buf();
assert_eq!(
tokio::fs::read_to_string(&path).await.unwrap(),
"enabled = true\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
drop(config);
assert!(!path.exists());
}
}

View File

@@ -103,11 +103,25 @@ impl Default for GostcConfig {
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EasytierConfigMode {
Quick,
Full,
}
impl Default for EasytierConfigMode {
fn default() -> Self {
Self::Quick
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
#[derive(Default)]
pub struct EasytierConfig {
pub enabled: bool,
pub config_mode: EasytierConfigMode,
pub network_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub network_secret: String,
@@ -115,6 +129,8 @@ pub struct EasytierConfig {
pub peer_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub virtual_ip: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub custom_toml: String,
}
#[typeshare]
@@ -260,3 +276,26 @@ pub struct ExtensionLogs {
pub id: ExtensionId,
pub logs: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::{EasytierConfig, EasytierConfigMode};
#[test]
fn legacy_easytier_config_defaults_to_quick_mode() {
let config: EasytierConfig = serde_json::from_str(
r#"{
"enabled": true,
"network_name": "legacy-network",
"network_secret": "secret",
"peer_urls": ["tcp://example.com:11010"],
"virtual_ip": "10.10.10.2/24"
}"#,
)
.expect("legacy EasyTier config should deserialize");
assert_eq!(config.config_mode, EasytierConfigMode::Quick);
assert!(config.custom_toml.is_empty());
assert_eq!(config.network_name, "legacy-network");
}
}

View File

@@ -0,0 +1,119 @@
use toml_edit::DocumentMut;
use super::types::{
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig,
FrpcConfigMode, GostcConfig,
};
pub(crate) fn validate_extension_config(
id: ExtensionId,
config: &ExtensionsConfig,
) -> Result<(), String> {
match id {
ExtensionId::Ttyd => Ok(()),
ExtensionId::Gostc => validate_gostc_config(&config.gostc),
ExtensionId::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc),
}
}
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?;
require_non_empty(config.key.as_str(), "GOSTC client key is required")
}
pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> {
match config.config_mode {
EasytierConfigMode::Quick => require_non_empty(
config.network_name.trim(),
"EasyTier network name is required",
),
EasytierConfigMode::Full => validate_full_toml("EasyTier", config.custom_toml.as_str()),
}
}
pub(crate) fn validate_frpc_config(config: &FrpcConfig) -> Result<(), String> {
match config.config_mode {
FrpcConfigMode::Quick => {
require_non_empty(config.proxy_name.trim(), "FRPC proxy name is required")?;
require_non_empty(config.server_addr.trim(), "FRPC server address is required")?;
require_non_empty(config.token.as_str(), "FRPC token is required")?;
require_non_empty(config.local_ip.trim(), "FRPC local IP is required")?;
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
&& config.remote_port.is_none()
{
return Err("FRPC remote port is required for TCP/UDP proxies".to_string());
}
Ok(())
}
FrpcConfigMode::Full => validate_full_toml("FRPC", config.custom_toml.as_str()),
}
}
fn require_non_empty(value: &str, message: &str) -> Result<(), String> {
if value.is_empty() {
Err(message.to_string())
} else {
Ok(())
}
}
fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String> {
let trimmed = config.trim();
if trimmed.is_empty() {
return Err(format!("{} full configuration is required", extension_name));
}
trimmed.parse::<DocumentMut>().map_err(|error| {
format!(
"{} full configuration is not valid TOML: {}",
extension_name, error
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_easytier_full_configuration() {
let mut config = EasytierConfig {
config_mode: EasytierConfigMode::Full,
..Default::default()
};
assert_eq!(
validate_easytier_config(&config).unwrap_err(),
"EasyTier full configuration is required"
);
config.custom_toml = "instance_name = [".to_string();
assert!(validate_easytier_config(&config)
.unwrap_err()
.starts_with("EasyTier full configuration is not valid TOML:"));
config.custom_toml = "instance_name = \"one-kvm\"".to_string();
assert!(validate_easytier_config(&config).is_ok());
}
#[test]
fn validates_frpc_through_the_same_entry_point() {
let mut config = FrpcConfig {
config_mode: FrpcConfigMode::Full,
..Default::default()
};
assert_eq!(
validate_frpc_config(&config).unwrap_err(),
"FRPC full configuration is required"
);
config.custom_toml = "serverAddr = \"frps.example.com\"".to_string();
assert!(validate_frpc_config(&config).is_ok());
}
}

View File

@@ -4,76 +4,19 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use toml_edit::DocumentMut;
use typeshare::typeshare;
use crate::error::{AppError, Result};
use crate::extensions::{
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig,
TtydInfo,
validate_easytier_config, validate_extension_config, validate_frpc_config,
validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId,
ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode,
FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
};
use crate::state::AppState;
fn validate_gostc_enabled(config: &GostcConfig) -> Result<()> {
if config.addr.trim().is_empty() {
return Err(AppError::BadRequest(
"GOSTC server address is required".into(),
));
}
if config.key.is_empty() {
return Err(AppError::BadRequest("GOSTC client key is required".into()));
}
Ok(())
}
fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> {
if config.network_name.trim().is_empty() {
return Err(AppError::BadRequest(
"EasyTier network name is required".into(),
));
}
Ok(())
}
fn validate_frpc_enabled(config: &FrpcConfig) -> Result<()> {
match config.config_mode {
FrpcConfigMode::Quick => {
if config.proxy_name.trim().is_empty() {
return Err(AppError::BadRequest("FRPC proxy name is required".into()));
}
if config.server_addr.trim().is_empty() {
return Err(AppError::BadRequest(
"FRPC server address is required".into(),
));
}
if config.token.is_empty() {
return Err(AppError::BadRequest("FRPC token is required".into()));
}
if config.local_ip.trim().is_empty() {
return Err(AppError::BadRequest("FRPC local IP is required".into()));
}
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
&& config.remote_port.is_none()
{
return Err(AppError::BadRequest(
"FRPC remote port is required for TCP/UDP proxies".into(),
));
}
}
FrpcConfigMode::Full => {
let toml = config.custom_toml.trim();
if toml.is_empty() {
return Err(AppError::BadRequest(
"FRPC full configuration is required".into(),
));
}
toml.parse::<DocumentMut>().map_err(|e| {
AppError::BadRequest(format!("FRPC full configuration is not valid TOML: {}", e))
})?;
}
}
Ok(())
fn bad_request(validation: std::result::Result<(), String>) -> Result<()> {
validation.map_err(AppError::BadRequest)
}
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
@@ -131,6 +74,8 @@ pub async fn start_extension(
let config = state.config.get();
let mgr = &state.extensions;
bad_request(validate_extension_config(ext_id, &config.extensions))?;
mgr.start(ext_id, &config.extensions)
.await
.map_err(AppError::Internal)?;
@@ -200,10 +145,12 @@ pub struct GostcConfigUpdate {
#[derive(Debug, Deserialize)]
pub struct EasytierConfigUpdate {
pub enabled: Option<bool>,
pub config_mode: Option<EasytierConfigMode>,
pub network_name: Option<String>,
pub network_secret: Option<String>,
pub peer_urls: Option<Vec<String>>,
pub virtual_ip: Option<String>,
pub custom_toml: Option<String>,
}
#[typeshare]
@@ -284,7 +231,7 @@ pub async fn update_gostc_config(
}
if next_gostc.enabled {
validate_gostc_enabled(&next_gostc)?;
bad_request(validate_gostc_config(&next_gostc))?;
}
state
@@ -321,6 +268,9 @@ pub async fn update_easytier_config(
if let Some(enabled) = req.enabled {
next_easytier.enabled = enabled;
}
if let Some(config_mode) = req.config_mode {
next_easytier.config_mode = config_mode;
}
if let Some(ref name) = req.network_name {
next_easytier.network_name = name.clone();
}
@@ -333,9 +283,12 @@ pub async fn update_easytier_config(
if req.virtual_ip.is_some() {
next_easytier.virtual_ip = req.virtual_ip.clone();
}
if let Some(ref custom_toml) = req.custom_toml {
next_easytier.custom_toml = custom_toml.clone();
}
if next_easytier.enabled {
validate_easytier_enabled(&next_easytier)?;
if next_easytier.enabled || matches!(next_easytier.config_mode, EasytierConfigMode::Full) {
bad_request(validate_easytier_config(&next_easytier))?;
}
state
@@ -414,7 +367,7 @@ pub async fn update_frpc_config(
}
if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) {
validate_frpc_enabled(&next_frpc)?;
bad_request(validate_frpc_config(&next_frpc))?;
}
state