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::collections::{HashMap, VecDeque};
use std::path::PathBuf; use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use toml_edit::DocumentMut;
use super::protected_config::ProtectedConfigFile;
use super::types::*; use super::types::*;
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config};
use crate::events::EventBus; use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200; const LOG_BUFFER_SIZE: usize = 200;
@@ -27,12 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
struct ExtensionProcess { struct ExtensionProcess {
child: Child, child: Child,
logs: Arc<RwLock<VecDeque<String>>>, logs: Arc<RwLock<VecDeque<String>>>,
_temp_dir: Option<TempDir>, _config_file: Option<ProtectedConfigFile>,
} }
struct ExtensionLaunch { struct ExtensionLaunch {
args: Vec<String>, args: Vec<String>,
temp_dir: Option<TempDir>, config_file: Option<ProtectedConfigFile>,
} }
pub struct ExtensionManager { pub struct ExtensionManager {
@@ -83,24 +83,12 @@ impl ExtensionManager {
match id { match id {
ExtensionId::Ttyd => config.ttyd.enabled, ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => { ExtensionId::Gostc => {
config.gostc.enabled config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
} }
ExtensionId::Easytier => { ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty() config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
}
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(),
}
} }
ExtensionId::Frpc => config.frpc.enabled && validate_frpc_config(&config.frpc).is_ok(),
} }
} }
@@ -203,7 +191,7 @@ impl ExtensionManager {
ExtensionProcess { ExtensionProcess {
child, child,
logs, logs,
_temp_dir: launch.temp_dir, _config_file: launch.config_file,
}, },
); );
drop(processes); drop(processes);
@@ -286,12 +274,7 @@ impl ExtensionManager {
ExtensionId::Gostc => { ExtensionId::Gostc => {
let c = &config.gostc; let c = &config.gostc;
if c.addr.trim().is_empty() { validate_gostc_config(c)?;
return Err("GOSTC server address is required".into());
}
if c.key.is_empty() {
return Err("GOSTC client key is required".into());
}
let mut args = Vec::new(); let mut args = Vec::new();
@@ -307,25 +290,59 @@ impl ExtensionManager {
} }
ExtensionId::Easytier => { ExtensionId::Easytier => {
let c = &config.easytier; return Self::build_easytier_launch(&config.easytier).await;
if c.network_name.is_empty() {
return Err("EasyTier network name is required".into());
} }
ExtensionId::Frpc => {
return Self::build_frpc_launch(&config.frpc).await;
}
};
Ok(ExtensionLaunch {
args,
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![ let mut args = vec![
"--network-name".to_string(), "--network-name".to_string(),
c.network_name.clone(), config.network_name.clone(),
"--network-secret".to_string(), "--network-secret".to_string(),
c.network_secret.clone(), config.network_secret.clone(),
]; ];
for peer in &c.peer_urls { for peer in &config.peer_urls {
if !peer.is_empty() { if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]); args.extend(["--peers".to_string(), peer.clone()]);
} }
} }
if let Some(ref ip) = c.virtual_ip { if let Some(ref ip) = config.virtual_ip {
if !ip.is_empty() { if !ip.is_empty() {
args.extend(["-i".to_string(), ip.clone()]); args.extend(["-i".to_string(), ip.clone()]);
} else { } else {
@@ -338,65 +355,23 @@ impl ExtensionManager {
args args
} }
ExtensionId::Frpc => {
return Self::build_frpc_launch(&config.frpc).await;
}
};
Ok(ExtensionLaunch {
args,
temp_dir: None,
})
}
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> { async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
validate_frpc_config(config)?;
let config_text = match config.config_mode { let config_text = match config.config_mode {
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?, 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 = let config_file =
tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?; ProtectedConfigFile::create("FRPC", "frpc.toml", config_text.as_str()).await?;
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))?;
}
Ok(ExtensionLaunch { Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(&config_path)], args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
temp_dir: Some(temp_dir), 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> { fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
if config.proxy_name.trim().is_empty() { if config.proxy_name.trim().is_empty() {
return Err("FRPC proxy name is required".into()); return Err("FRPC proxy name is required".into());
@@ -480,7 +455,7 @@ impl ExtensionManager {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) 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() path.to_string_lossy().to_string()
} }
@@ -603,3 +578,107 @@ impl ExtensionManager {
futures::future::join_all(stop_futures).await; 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 manager;
mod protected_config;
mod software; mod software;
mod types; mod types;
mod validation;
pub use manager::ExtensionManager; pub use manager::ExtensionManager;
#[cfg(unix)] #[cfg(unix)]
@@ -8,3 +10,7 @@ pub use manager::TTYD_SOCKET_PATH;
#[cfg(windows)] #[cfg(windows)]
pub use manager::TTYD_TCP_ADDR; pub use manager::TTYD_TCP_ADDR;
pub use types::*; 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] #[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)] #[serde(default)]
#[derive(Default)]
pub struct EasytierConfig { pub struct EasytierConfig {
pub enabled: bool, pub enabled: bool,
pub config_mode: EasytierConfigMode,
pub network_name: String, pub network_name: String,
#[serde(skip_serializing_if = "String::is_empty")] #[serde(skip_serializing_if = "String::is_empty")]
pub network_secret: String, pub network_secret: String,
@@ -115,6 +129,8 @@ pub struct EasytierConfig {
pub peer_urls: Vec<String>, pub peer_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub virtual_ip: Option<String>, pub virtual_ip: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub custom_toml: String,
} }
#[typeshare] #[typeshare]
@@ -260,3 +276,26 @@ pub struct ExtensionLogs {
pub id: ExtensionId, pub id: ExtensionId,
pub logs: Vec<String>, 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 serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use toml_edit::DocumentMut;
use typeshare::typeshare; use typeshare::typeshare;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::extensions::{ use crate::extensions::{
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, validate_easytier_config, validate_extension_config, validate_frpc_config,
FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig, validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId,
TtydInfo, ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode,
FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
}; };
use crate::state::AppState; use crate::state::AppState;
fn validate_gostc_enabled(config: &GostcConfig) -> Result<()> { fn bad_request(validation: std::result::Result<(), String>) -> Result<()> {
if config.addr.trim().is_empty() { validation.map_err(AppError::BadRequest)
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(())
} }
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> { 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 config = state.config.get();
let mgr = &state.extensions; let mgr = &state.extensions;
bad_request(validate_extension_config(ext_id, &config.extensions))?;
mgr.start(ext_id, &config.extensions) mgr.start(ext_id, &config.extensions)
.await .await
.map_err(AppError::Internal)?; .map_err(AppError::Internal)?;
@@ -200,10 +145,12 @@ pub struct GostcConfigUpdate {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct EasytierConfigUpdate { pub struct EasytierConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub config_mode: Option<EasytierConfigMode>,
pub network_name: Option<String>, pub network_name: Option<String>,
pub network_secret: Option<String>, pub network_secret: Option<String>,
pub peer_urls: Option<Vec<String>>, pub peer_urls: Option<Vec<String>>,
pub virtual_ip: Option<String>, pub virtual_ip: Option<String>,
pub custom_toml: Option<String>,
} }
#[typeshare] #[typeshare]
@@ -284,7 +231,7 @@ pub async fn update_gostc_config(
} }
if next_gostc.enabled { if next_gostc.enabled {
validate_gostc_enabled(&next_gostc)?; bad_request(validate_gostc_config(&next_gostc))?;
} }
state state
@@ -321,6 +268,9 @@ pub async fn update_easytier_config(
if let Some(enabled) = req.enabled { if let Some(enabled) = req.enabled {
next_easytier.enabled = 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 { if let Some(ref name) = req.network_name {
next_easytier.network_name = name.clone(); next_easytier.network_name = name.clone();
} }
@@ -333,9 +283,12 @@ pub async fn update_easytier_config(
if req.virtual_ip.is_some() { if req.virtual_ip.is_some() {
next_easytier.virtual_ip = req.virtual_ip.clone(); 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 { if next_easytier.enabled || matches!(next_easytier.config_mode, EasytierConfigMode::Full) {
validate_easytier_enabled(&next_easytier)?; bad_request(validate_easytier_config(&next_easytier))?;
} }
state state
@@ -414,7 +367,7 @@ pub async fn update_frpc_config(
} }
if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) { if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) {
validate_frpc_enabled(&next_frpc)?; bad_request(validate_frpc_config(&next_frpc))?;
} }
state state

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Textarea } from '@/components/ui/textarea'
defineProps<{
disabled: boolean
quickLabel: string
fullLabel: string
fullConfigHint: string
fullConfigRequired: string
}>()
const mode = defineModel<'quick' | 'full'>('mode', { required: true })
const config = defineModel<string>('config', { required: true })
</script>
<template>
<ButtonGroup class="grid w-full grid-cols-2">
<Button
type="button"
:variant="mode === 'quick' ? 'default' : 'outline'"
:disabled="disabled"
@click="mode = 'quick'"
>
{{ quickLabel }}
</Button>
<Button
type="button"
:variant="mode === 'full' ? 'default' : 'outline'"
:disabled="disabled"
@click="mode = 'full'"
>
{{ fullLabel }}
</Button>
</ButtonGroup>
<slot v-if="mode === 'quick'" name="quick" />
<div v-else class="space-y-1">
<Textarea
v-model="config"
class="min-h-[300px] font-mono text-xs"
spellcheck="false"
:disabled="disabled"
/>
<p class="text-xs text-muted-foreground">{{ fullConfigHint }}</p>
<p v-if="!config.trim()" class="text-xs text-destructive">{{ fullConfigRequired }}</p>
</div>
</template>

View File

@@ -1030,6 +1030,10 @@ export default {
easytier: { easytier: {
title: 'Easytier Network', title: 'Easytier Network',
desc: 'P2P VPN networking via EasyTier', desc: 'P2P VPN networking via EasyTier',
quickConfig: 'Quick Config',
fullConfig: 'Full Config',
fullConfigHint: 'Paste the full easytier.toml configuration file here',
fullConfigRequired: 'Enter the full easytier.toml configuration',
networkName: 'Network Name', networkName: 'Network Name',
networkNameRequired: 'Enter the EasyTier network name', networkNameRequired: 'Enter the EasyTier network name',
networkSecret: 'Network Secret', networkSecret: 'Network Secret',

View File

@@ -1029,6 +1029,10 @@ export default {
easytier: { easytier: {
title: 'Easytier 组网', title: 'Easytier 组网',
desc: '通过 EasyTier 实现 P2P VPN 组网', desc: '通过 EasyTier 实现 P2P VPN 组网',
quickConfig: '快速配置',
fullConfig: '完整配置',
fullConfigHint: '可在此粘贴完整的 easytier.toml 配置文件',
fullConfigRequired: '请填写完整 easytier.toml 配置',
networkName: '网络名称', networkName: '网络名称',
networkNameRequired: '请填写 EasyTier 网络名称', networkNameRequired: '请填写 EasyTier 网络名称',
networkSecret: '网络密钥', networkSecret: '网络密钥',

View File

@@ -194,12 +194,19 @@ export interface GostcConfig {
tls: boolean; tls: boolean;
} }
export enum EasytierConfigMode {
Quick = "quick",
Full = "full",
}
export interface EasytierConfig { export interface EasytierConfig {
enabled: boolean; enabled: boolean;
config_mode: EasytierConfigMode;
network_name: string; network_name: string;
network_secret: string; network_secret: string;
peer_urls: string[]; peer_urls: string[];
virtual_ip?: string; virtual_ip?: string;
custom_toml: string;
} }
export enum FrpcConfigMode { export enum FrpcConfigMode {
@@ -430,10 +437,12 @@ export interface ComputerUseStartRequest {
export interface EasytierConfigUpdate { export interface EasytierConfigUpdate {
enabled?: boolean; enabled?: boolean;
config_mode?: EasytierConfigMode;
network_name?: string; network_name?: string;
network_secret?: string; network_secret?: string;
peer_urls?: string[]; peer_urls?: string[];
virtual_ip?: string; virtual_ip?: string;
custom_toml?: string;
} }
export type ExtensionStatus = export type ExtensionStatus =

View File

@@ -54,7 +54,7 @@ import type {
OtgNetworkStatus, OtgNetworkStatus,
WatchdogConfigResponse, WatchdogConfigResponse,
} from '@/types/generated' } from '@/types/generated'
import { FrpProxyType, FrpcConfigMode } from '@/types/generated' import { EasytierConfigMode, FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { toConfigFps } from '@/lib/fps' import { toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
@@ -67,6 +67,7 @@ import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import TerminalDialog from '@/components/TerminalDialog.vue' import TerminalDialog from '@/components/TerminalDialog.vue'
import TotpSettingsCard from '@/components/TotpSettingsCard.vue' import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
import VideoInputFields from '@/components/VideoInputFields.vue' import VideoInputFields from '@/components/VideoInputFields.vue'
import ExtensionConfigModeEditor from '@/components/ExtensionConfigModeEditor.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -76,7 +77,6 @@ import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ButtonGroup } from '@/components/ui/button-group'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@/components/ui/empty' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -427,7 +427,15 @@ const showTerminalDialog = ref(false)
const extConfig = ref({ const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' }, ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: '', key: '', tls: true }, gostc: { enabled: false, addr: '', key: '', tls: true },
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' }, easytier: {
enabled: false,
config_mode: EasytierConfigMode.Quick,
network_name: '',
network_secret: '',
peer_urls: [] as string[],
virtual_ip: '',
custom_toml: '',
},
frpc: { frpc: {
enabled: false, enabled: false,
config_mode: FrpcConfigMode.Quick, config_mode: FrpcConfigMode.Quick,
@@ -453,6 +461,10 @@ const gostcValidationMessage = computed(() => {
}) })
const easytierValidationMessage = computed(() => { const easytierValidationMessage = computed(() => {
if (extConfig.value.easytier.config_mode === EasytierConfigMode.Full) {
if (!extConfig.value.easytier.custom_toml?.trim()) return t('extensions.easytier.fullConfigRequired')
return ''
}
if (!extConfig.value.easytier.network_name?.trim()) return t('extensions.easytier.networkNameRequired') if (!extConfig.value.easytier.network_name?.trim()) return t('extensions.easytier.networkNameRequired')
return '' return ''
}) })
@@ -1629,10 +1641,12 @@ async function loadExtensions() {
const easytier = extensions.value.easytier.config const easytier = extensions.value.easytier.config
extConfig.value.easytier = { extConfig.value.easytier = {
enabled: easytier.enabled, enabled: easytier.enabled,
config_mode: easytier.config_mode || EasytierConfigMode.Quick,
network_name: easytier.network_name, network_name: easytier.network_name,
network_secret: easytier.network_secret, network_secret: easytier.network_secret,
peer_urls: easytier.peer_urls || [], peer_urls: easytier.peer_urls || [],
virtual_ip: easytier.virtual_ip || '', virtual_ip: easytier.virtual_ip || '',
custom_toml: easytier.custom_toml || '',
} }
const frpc = extensions.value.frpc.config const frpc = extensions.value.frpc.config
extConfig.value.frpc = { extConfig.value.frpc = {
@@ -1690,6 +1704,7 @@ async function refreshExtensionLogs(id: ExtensionConfigId) {
async function saveExtensionConfig(id: ExtensionConfigId) { async function saveExtensionConfig(id: ExtensionConfigId) {
if (id !== 'ttyd') { if (id !== 'ttyd') {
const shouldValidate = extConfig.value[id].enabled const shouldValidate = extConfig.value[id].enabled
|| (id === 'easytier' && extConfig.value.easytier.config_mode === EasytierConfigMode.Full)
|| (id === 'frpc' && extConfig.value.frpc.config_mode === FrpcConfigMode.Full) || (id === 'frpc' && extConfig.value.frpc.config_mode === FrpcConfigMode.Full)
if (shouldValidate && !validateExtensionConfig(id)) return if (shouldValidate && !validateExtensionConfig(id)) return
} }
@@ -4516,6 +4531,16 @@ watch(isWindows, () => {
<Label>{{ t('extensions.autoStart') }}</Label> <Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.easytier.enabled" :disabled="isExtRunning(extensions?.easytier?.status)" /> <Switch v-model="extConfig.easytier.enabled" :disabled="isExtRunning(extensions?.easytier?.status)" />
</div> </div>
<ExtensionConfigModeEditor
v-model:mode="extConfig.easytier.config_mode"
v-model:config="extConfig.easytier.custom_toml"
:disabled="isExtRunning(extensions?.easytier?.status)"
:quick-label="t('extensions.easytier.quickConfig')"
:full-label="t('extensions.easytier.fullConfig')"
:full-config-hint="t('extensions.easytier.fullConfigHint')"
:full-config-required="t('extensions.easytier.fullConfigRequired')"
>
<template #quick>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center"> <div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label> <Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
<div class="sm:col-span-3 space-y-1"> <div class="sm:col-span-3 space-y-1">
@@ -4548,6 +4573,8 @@ watch(isWindows, () => {
<Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" /> <Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" />
</div> </div>
</div> </div>
</template>
</ExtensionConfigModeEditor>
</div> </div>
<!-- Logs --> <!-- Logs -->
<div class="space-y-2"> <div class="space-y-2">
@@ -4621,25 +4648,16 @@ watch(isWindows, () => {
<Label>{{ t('extensions.autoStart') }}</Label> <Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.frpc.enabled" :disabled="isExtRunning(extensions?.frpc?.status)" /> <Switch v-model="extConfig.frpc.enabled" :disabled="isExtRunning(extensions?.frpc?.status)" />
</div> </div>
<ButtonGroup class="grid w-full grid-cols-2"> <ExtensionConfigModeEditor
<Button v-model:mode="extConfig.frpc.config_mode"
type="button" v-model:config="extConfig.frpc.custom_toml"
:variant="frpcQuickMode ? 'default' : 'outline'"
:disabled="isExtRunning(extensions?.frpc?.status)" :disabled="isExtRunning(extensions?.frpc?.status)"
@click="extConfig.frpc.config_mode = FrpcConfigMode.Quick" :quick-label="t('extensions.frpc.quickConfig')"
:full-label="t('extensions.frpc.fullConfig')"
:full-config-hint="t('extensions.frpc.fullConfigHint')"
:full-config-required="t('extensions.frpc.fullConfigRequired')"
> >
{{ t('extensions.frpc.quickConfig') }} <template #quick>
</Button>
<Button
type="button"
:variant="!frpcQuickMode ? 'default' : 'outline'"
:disabled="isExtRunning(extensions?.frpc?.status)"
@click="extConfig.frpc.config_mode = FrpcConfigMode.Full"
>
{{ t('extensions.frpc.fullConfig') }}
</Button>
</ButtonGroup>
<template v-if="frpcQuickMode">
<div class="grid gap-2 sm:grid-cols-4 sm:items-center"> <div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.frpc.proxyType') }}</Label> <Label class="sm:text-right">{{ t('extensions.frpc.proxyType') }}</Label>
<div class="sm:col-span-3"> <div class="sm:col-span-3">
@@ -4709,16 +4727,7 @@ watch(isWindows, () => {
</div> </div>
</div> </div>
</template> </template>
<div v-else class="space-y-1"> </ExtensionConfigModeEditor>
<Textarea
v-model="extConfig.frpc.custom_toml"
class="min-h-[300px] font-mono text-xs"
spellcheck="false"
:disabled="isExtRunning(extensions?.frpc?.status)"
/>
<p class="text-xs text-muted-foreground">{{ t('extensions.frpc.fullConfigHint') }}</p>
<p v-if="!extConfig.frpc.custom_toml?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.fullConfigRequired') }}</p>
</div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Collapsible v-model:open="showLogs.frpc" @update:open="open => open && refreshExtensionLogs('frpc')"> <Collapsible v-model:open="showLogs.frpc" @update:open="open => open && refreshExtensionLogs('frpc')">