mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 10:34:24 +08:00
feat: 为 EasyTier 添加完整 TOML 配置支持
- 新增快速配置与完整配置模式,兼容旧版配置 - 统一扩展配置校验及受保护临时文件生命周期 - 支持通过 easytier-core -c 加载 TOML 配置 - 复用前端配置模式编辑组件并更新中英文文案与类型 - 补充配置校验、启动参数、文件权限及清理测试
This commit is contained in:
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
95
src/extensions/protected_config.rs
Normal file
95
src/extensions/protected_config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
119
src/extensions/validation.rs
Normal file
119
src/extensions/validation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
49
web/src/components/ExtensionConfigModeEditor.vue
Normal file
49
web/src/components/ExtensionConfigModeEditor.vue
Normal 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>
|
||||
@@ -1030,6 +1030,10 @@ export default {
|
||||
easytier: {
|
||||
title: 'Easytier Network',
|
||||
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',
|
||||
networkNameRequired: 'Enter the EasyTier network name',
|
||||
networkSecret: 'Network Secret',
|
||||
|
||||
@@ -1029,6 +1029,10 @@ export default {
|
||||
easytier: {
|
||||
title: 'Easytier 组网',
|
||||
desc: '通过 EasyTier 实现 P2P VPN 组网',
|
||||
quickConfig: '快速配置',
|
||||
fullConfig: '完整配置',
|
||||
fullConfigHint: '可在此粘贴完整的 easytier.toml 配置文件',
|
||||
fullConfigRequired: '请填写完整 easytier.toml 配置',
|
||||
networkName: '网络名称',
|
||||
networkNameRequired: '请填写 EasyTier 网络名称',
|
||||
networkSecret: '网络密钥',
|
||||
|
||||
@@ -194,12 +194,19 @@ export interface GostcConfig {
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export enum EasytierConfigMode {
|
||||
Quick = "quick",
|
||||
Full = "full",
|
||||
}
|
||||
|
||||
export interface EasytierConfig {
|
||||
enabled: boolean;
|
||||
config_mode: EasytierConfigMode;
|
||||
network_name: string;
|
||||
network_secret: string;
|
||||
peer_urls: string[];
|
||||
virtual_ip?: string;
|
||||
custom_toml: string;
|
||||
}
|
||||
|
||||
export enum FrpcConfigMode {
|
||||
@@ -430,10 +437,12 @@ export interface ComputerUseStartRequest {
|
||||
|
||||
export interface EasytierConfigUpdate {
|
||||
enabled?: boolean;
|
||||
config_mode?: EasytierConfigMode;
|
||||
network_name?: string;
|
||||
network_secret?: string;
|
||||
peer_urls?: string[];
|
||||
virtual_ip?: string;
|
||||
custom_toml?: string;
|
||||
}
|
||||
|
||||
export type ExtensionStatus =
|
||||
|
||||
@@ -54,7 +54,7 @@ import type {
|
||||
OtgNetworkStatus,
|
||||
WatchdogConfigResponse,
|
||||
} from '@/types/generated'
|
||||
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
|
||||
import { EasytierConfigMode, FrpProxyType, FrpcConfigMode } from '@/types/generated'
|
||||
import { toConfigFps } from '@/lib/fps'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
@@ -67,6 +67,7 @@ import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import TerminalDialog from '@/components/TerminalDialog.vue'
|
||||
import TotpSettingsCard from '@/components/TotpSettingsCard.vue'
|
||||
import VideoInputFields from '@/components/VideoInputFields.vue'
|
||||
import ExtensionConfigModeEditor from '@/components/ExtensionConfigModeEditor.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -76,7 +77,6 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
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 { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -427,7 +427,15 @@ const showTerminalDialog = ref(false)
|
||||
const extConfig = ref({
|
||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||
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: {
|
||||
enabled: false,
|
||||
config_mode: FrpcConfigMode.Quick,
|
||||
@@ -453,6 +461,10 @@ const gostcValidationMessage = 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')
|
||||
return ''
|
||||
})
|
||||
@@ -1629,10 +1641,12 @@ async function loadExtensions() {
|
||||
const easytier = extensions.value.easytier.config
|
||||
extConfig.value.easytier = {
|
||||
enabled: easytier.enabled,
|
||||
config_mode: easytier.config_mode || EasytierConfigMode.Quick,
|
||||
network_name: easytier.network_name,
|
||||
network_secret: easytier.network_secret,
|
||||
peer_urls: easytier.peer_urls || [],
|
||||
virtual_ip: easytier.virtual_ip || '',
|
||||
custom_toml: easytier.custom_toml || '',
|
||||
}
|
||||
const frpc = extensions.value.frpc.config
|
||||
extConfig.value.frpc = {
|
||||
@@ -1690,6 +1704,7 @@ async function refreshExtensionLogs(id: ExtensionConfigId) {
|
||||
async function saveExtensionConfig(id: ExtensionConfigId) {
|
||||
if (id !== 'ttyd') {
|
||||
const shouldValidate = extConfig.value[id].enabled
|
||||
|| (id === 'easytier' && extConfig.value.easytier.config_mode === EasytierConfigMode.Full)
|
||||
|| (id === 'frpc' && extConfig.value.frpc.config_mode === FrpcConfigMode.Full)
|
||||
if (shouldValidate && !validateExtensionConfig(id)) return
|
||||
}
|
||||
@@ -4516,38 +4531,50 @@ watch(isWindows, () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="extConfig.easytier.enabled" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.network_name" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<p v-if="extConfig.easytier.enabled && !extConfig.easytier.network_name?.trim()" class="text-xs text-destructive">{{ t('extensions.easytier.networkNameRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_secret" type="password" autocomplete="off" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.peers') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div v-for="(_, i) in extConfig.easytier.peer_urls" :key="i" class="flex gap-2">
|
||||
<Input v-model="extConfig.easytier.peer_urls[i]" placeholder="tcp://1.2.3.4:11010" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeEasytierPeer(i)" :disabled="isExtRunning(extensions?.easytier?.status)">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
<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">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.network_name" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<p v-if="extConfig.easytier.enabled && !extConfig.easytier.network_name?.trim()" class="text-xs text-destructive">{{ t('extensions.easytier.networkNameRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addEasytierPeer" :disabled="isExtRunning(extensions?.easytier?.status)">
|
||||
<Plus class="size-4 mr-1" />
|
||||
{{ t('extensions.easytier.addPeer') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_secret" type="password" autocomplete="off" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.peers') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<div v-for="(_, i) in extConfig.easytier.peer_urls" :key="i" class="flex gap-2">
|
||||
<Input v-model="extConfig.easytier.peer_urls[i]" placeholder="tcp://1.2.3.4:11010" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeEasytierPeer(i)" :disabled="isExtRunning(extensions?.easytier?.status)">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addEasytierPeer" :disabled="isExtRunning(extensions?.easytier?.status)">
|
||||
<Plus class="size-4 mr-1" />
|
||||
{{ t('extensions.easytier.addPeer') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.virtualIp') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.virtual_ip" placeholder="10.0.0.1/24" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ExtensionConfigModeEditor>
|
||||
</div>
|
||||
<!-- Logs -->
|
||||
<div class="space-y-2">
|
||||
@@ -4621,104 +4648,86 @@ watch(isWindows, () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="extConfig.frpc.enabled" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<ButtonGroup class="grid w-full grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
:variant="frpcQuickMode ? 'default' : 'outline'"
|
||||
:disabled="isExtRunning(extensions?.frpc?.status)"
|
||||
@click="extConfig.frpc.config_mode = FrpcConfigMode.Quick"
|
||||
>
|
||||
{{ t('extensions.frpc.quickConfig') }}
|
||||
</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">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.proxyType') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<RadioGroup v-model="extConfig.frpc.proxy_type" class="flex flex-wrap gap-4" :disabled="isExtRunning(extensions?.frpc?.status)">
|
||||
<div v-for="type in ['tcp', 'udp', 'http', 'https', 'stcp', 'sudp', 'xtcp']" :key="type" class="flex items-center space-x-2">
|
||||
<RadioGroupItem :value="type" :id="`frpc-${type}`" />
|
||||
<Label :for="`frpc-${type}`" class="cursor-pointer uppercase">{{ type }}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<ExtensionConfigModeEditor
|
||||
v-model:mode="extConfig.frpc.config_mode"
|
||||
v-model:config="extConfig.frpc.custom_toml"
|
||||
:disabled="isExtRunning(extensions?.frpc?.status)"
|
||||
: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')"
|
||||
>
|
||||
<template #quick>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.proxyType') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<RadioGroup v-model="extConfig.frpc.proxy_type" class="flex flex-wrap gap-4" :disabled="isExtRunning(extensions?.frpc?.status)">
|
||||
<div v-for="type in ['tcp', 'udp', 'http', 'https', 'stcp', 'sudp', 'xtcp']" :key="type" class="flex items-center space-x-2">
|
||||
<RadioGroupItem :value="type" :id="`frpc-${type}`" />
|
||||
<Label :for="`frpc-${type}`" class="cursor-pointer uppercase">{{ type }}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.proxyName') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.proxy_name" :placeholder="t('extensions.frpc.proxyNamePlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.proxy_name?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.proxyNameRequired') }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.proxyName') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.proxy_name" :placeholder="t('extensions.frpc.proxyNamePlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.proxy_name?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.proxyNameRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.serverAddr') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.server_addr" :placeholder="t('extensions.frpc.serverAddrPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.server_addr?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.serverAddrRequired') }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.serverAddr') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.server_addr" :placeholder="t('extensions.frpc.serverAddrPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.server_addr?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.serverAddrRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.serverPort') }}</Label>
|
||||
<Input v-model.number="extConfig.frpc.server_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.token') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.token" type="password" autocomplete="off" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.token" class="text-xs text-destructive">{{ t('extensions.frpc.tokenRequired') }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.serverPort') }}</Label>
|
||||
<Input v-model.number="extConfig.frpc.server_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.localIp') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.local_ip" placeholder="127.0.0.1" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.local_ip?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.localIpRequired') }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.token') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.token" type="password" autocomplete="off" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.token" class="text-xs text-destructive">{{ t('extensions.frpc.tokenRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.localPort') }}</Label>
|
||||
<Input v-model.number="extConfig.frpc.local_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div v-if="showFrpcRemotePort" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.remotePort') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model.number="extConfig.frpc.remote_port" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && frpcRemotePortRequired && !extConfig.frpc.remote_port" class="text-xs text-destructive">{{ t('extensions.frpc.remotePortRequired') }}</p>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.localIp') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.frpc.local_ip" placeholder="127.0.0.1" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.local_ip?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.localIpRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showFrpcCustomDomain" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.customDomain') }}</Label>
|
||||
<Input v-model="extConfig.frpc.custom_domain" class="sm:col-span-3" :placeholder="t('extensions.frpc.customDomainPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div v-if="showFrpcSecretKey" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.secretKey') }}</Label>
|
||||
<Input v-model="extConfig.frpc.secret_key" class="sm:col-span-3" type="password" autocomplete="off" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.tls') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Switch v-model="extConfig.frpc.tls" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.localPort') }}</Label>
|
||||
<Input v-model.number="extConfig.frpc.local_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="space-y-1">
|
||||
<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 v-if="showFrpcRemotePort" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.remotePort') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model.number="extConfig.frpc.remote_port" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
<p v-if="extConfig.frpc.enabled && frpcRemotePortRequired && !extConfig.frpc.remote_port" class="text-xs text-destructive">{{ t('extensions.frpc.remotePortRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showFrpcCustomDomain" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.customDomain') }}</Label>
|
||||
<Input v-model="extConfig.frpc.custom_domain" class="sm:col-span-3" :placeholder="t('extensions.frpc.customDomainPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div v-if="showFrpcSecretKey" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.secretKey') }}</Label>
|
||||
<Input v-model="extConfig.frpc.secret_key" class="sm:col-span-3" type="password" autocomplete="off" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.frpc.tls') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Switch v-model="extConfig.frpc.tls" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ExtensionConfigModeEditor>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Collapsible v-model:open="showLogs.frpc" @update:open="open => open && refreshExtensionLogs('frpc')">
|
||||
|
||||
Reference in New Issue
Block a user