From 5de7ecd4c50b359f3460d63141f61acfb5c3764d Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 13 Jun 2026 16:05:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20frp=20=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E8=AE=BF=E9=97=AE=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 6 + src/extensions/manager.rs | 245 ++++++++++++++++++++------ src/extensions/software_linux.rs | 1 + src/extensions/software_windows.rs | 1 + src/extensions/types.rs | 94 +++++++++- src/web/handlers/extensions.rs | 146 ++++++++++++++- src/web/routes.rs | 4 + web/src/api/config.ts | 8 + web/src/i18n/en-US.ts | 30 +++- web/src/i18n/zh-CN.ts | 30 +++- web/src/types/generated.ts | 59 ++++++- web/src/views/SettingsView.vue | 273 +++++++++++++++++++++++++++-- 12 files changed, 828 insertions(+), 69 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 931f7bfd..097ee2da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ desktop = [ "dep:sqlx", "dep:serde", "dep:serde_json", + "dep:toml_edit", "dep:tracing", "dep:tracing-subscriber", "dep:thiserror", @@ -38,6 +39,7 @@ desktop = [ "dep:axum-server", "dep:clap", "dep:time", + "dep:tempfile", "dep:bytes", "dep:bytemuck", "dep:xxhash-rust", @@ -98,6 +100,7 @@ android = [ "dep:sdp-types", "dep:serde", "dep:serde_json", + "dep:toml_edit", "dep:serialport", "dep:sha2", "dep:sodiumoxide", @@ -106,6 +109,7 @@ android = [ "dep:audiopus", "dep:thiserror", "dep:time", + "dep:tempfile", "dep:tokio", "dep:tokio-tungstenite", "dep:tokio-util", @@ -143,6 +147,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = tru # Serialization serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } +toml_edit = { version = "0.25", optional = true } # Logging tracing = { version = "0.1", optional = true } @@ -160,6 +165,7 @@ rand = { version = "0.9", optional = true } # Utilities uuid = { version = "1", features = ["v4", "serde"], optional = true } base64 = { version = "0.22", optional = true } +tempfile = { version = "3", optional = true } # HTTP client (for URL downloads) # Use rustls by default, but allow native-tls for systems with older GLIBC diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index a1474e4d..89477d15 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -1,16 +1,18 @@ use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; 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::types::*; use crate::events::EventBus; const LOG_BUFFER_SIZE: usize = 200; -const LOG_BATCH_SIZE: usize = 16; #[cfg(unix)] pub const TTYD_SOCKET_PATH: &str = "/var/run/one-kvm/ttyd.sock"; @@ -25,6 +27,12 @@ const TTYD_TCP_PORT: &str = "7681"; struct ExtensionProcess { child: Child, logs: Arc>>, + _temp_dir: Option, +} + +struct ExtensionLaunch { + args: Vec, + temp_dir: Option, } pub struct ExtensionManager { @@ -82,6 +90,17 @@ impl ExtensionManager { 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(), + } + } } } @@ -135,17 +154,17 @@ impl ExtensionManager { self.stop(id).await.ok(); - let args = self.build_args(id, config).await?; + let launch = self.build_launch(id, config).await?; tracing::info!( "Starting extension {}: {} {}", id, id.binary_path().display(), - Self::redact_args_for_log(&args).join(" ") + launch.args.join(" ") ); let mut child = Command::new(id.binary_path()) - .args(&args) + .args(&launch.args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -172,9 +191,21 @@ impl ExtensionManager { let pid = child.id(); tracing::info!("Extension {} started with PID {:?}", id, pid); + Self::push_log( + &logs, + format!("Extension {} started with PID {:?}", id, pid), + ) + .await; let mut processes = self.processes.write().await; - processes.insert(id, ExtensionProcess { child, logs }); + processes.insert( + id, + ExtensionProcess { + child, + logs, + _temp_dir: launch.temp_dir, + }, + ); drop(processes); self.mark_ttyd_status_dirty(id).await; @@ -212,22 +243,14 @@ impl ExtensionManager { ) { let reader = BufReader::new(reader); let mut lines = reader.lines(); - let mut local_buffer = Vec::with_capacity(LOG_BATCH_SIZE); loop { match lines.next_line().await { Ok(Some(line)) => { tracing::info!("[{}] {}", id, line); - local_buffer.push(line); - - if local_buffer.len() >= LOG_BATCH_SIZE { - Self::flush_logs(&logs, &mut local_buffer).await; - } + Self::push_log(&logs, line).await; } Ok(None) => { - if !local_buffer.is_empty() { - Self::flush_logs(&logs, &mut local_buffer).await; - } break; } Err(e) => { @@ -238,29 +261,27 @@ impl ExtensionManager { } } - async fn flush_logs(logs: &RwLock>, buffer: &mut Vec) { + async fn push_log(logs: &RwLock>, line: String) { let mut logs = logs.write().await; - for line in buffer.drain(..) { - if logs.len() >= LOG_BUFFER_SIZE { - logs.pop_front(); - } - logs.push_back(line); + if logs.len() >= LOG_BUFFER_SIZE { + logs.pop_front(); } + logs.push_back(line); } - async fn build_args( + async fn build_launch( &self, id: ExtensionId, config: &ExtensionsConfig, - ) -> Result, String> { - match id { + ) -> Result { + let args = match id { ExtensionId::Ttyd => { let c = &config.ttyd; let mut args = Self::build_ttyd_listen_args().await?; args.push(c.shell.clone()); - Ok(args) + args } ExtensionId::Gostc => { @@ -282,7 +303,7 @@ impl ExtensionManager { args.extend(["-key".to_string(), c.key.clone()]); - Ok(args) + args } ExtensionId::Easytier => { @@ -314,9 +335,153 @@ impl ExtensionManager { args.push("-d".to_string()); } - Ok(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 { + 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(), + }; + + 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))?; + } + + Ok(ExtensionLaunch { + args: vec!["-c".to_string(), Self::path_to_arg(&config_path)], + temp_dir: Some(temp_dir), + }) + } + + 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::() + .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 { + if config.proxy_name.trim().is_empty() { + return Err("FRPC proxy name is required".into()); + } + if config.server_addr.trim().is_empty() { + return Err("FRPC server address is required".into()); + } + if config.token.is_empty() { + return Err("FRPC token is required".into()); + } + if config.local_ip.trim().is_empty() { + return Err("FRPC local IP is required".into()); + } + + let proxy_type = match config.proxy_type { + FrpProxyType::Tcp => "tcp", + FrpProxyType::Udp => "udp", + FrpProxyType::Http => "http", + FrpProxyType::Https => "https", + FrpProxyType::Stcp => "stcp", + FrpProxyType::Sudp => "sudp", + FrpProxyType::Xtcp => "xtcp", + }; + + let mut toml = String::new(); + toml.push_str(&format!( + "serverAddr = {}\nserverPort = {}\n\n", + Self::toml_string(config.server_addr.trim()), + config.server_port + )); + toml.push_str("[auth]\n"); + toml.push_str("method = \"token\"\n"); + toml.push_str(&format!("token = {}\n\n", Self::toml_string(&config.token))); + toml.push_str("[transport]\n"); + toml.push_str("protocol = \"tcp\"\n\n"); + toml.push_str("[transport.tls]\n"); + toml.push_str(&format!("enable = {}\n\n", config.tls)); + toml.push_str("[[proxies]]\n"); + toml.push_str(&format!( + "name = {}\ntype = {}\nlocalIP = {}\nlocalPort = {}\n", + Self::toml_string(config.proxy_name.trim()), + Self::toml_string(proxy_type), + Self::toml_string(config.local_ip.trim()), + config.local_port + )); + + match config.proxy_type { + FrpProxyType::Tcp | FrpProxyType::Udp => { + let remote_port = config.remote_port.ok_or_else(|| { + "FRPC remote port is required for TCP/UDP proxies".to_string() + })?; + toml.push_str(&format!("remotePort = {}\n", remote_port)); + } + FrpProxyType::Http | FrpProxyType::Https => { + if let Some(domain) = config + .custom_domain + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + toml.push_str(&format!( + "customDomains = [{}]\n", + Self::toml_string(domain) + )); + } + } + FrpProxyType::Stcp | FrpProxyType::Sudp | FrpProxyType::Xtcp => { + if !config.secret_key.is_empty() { + toml.push_str(&format!( + "secretKey = {}\n", + Self::toml_string(&config.secret_key) + )); + } } } + + Ok(toml) + } + + fn toml_string(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) + } + + fn path_to_arg(path: &PathBuf) -> String { + path.to_string_lossy().to_string() } #[cfg(unix)] @@ -356,34 +521,6 @@ impl ExtensionManager { ]) } - fn redact_args_for_log(args: &[String]) -> Vec { - let mut redacted = Vec::with_capacity(args.len()); - let mut redact_next = false; - - for arg in args { - if redact_next { - redacted.push("****".to_string()); - redact_next = false; - continue; - } - - if arg == "-key" || arg == "--key" { - redacted.push(arg.clone()); - redact_next = true; - } else if let Some((flag, _)) = arg.split_once('=') { - if flag == "-key" || flag == "--key" { - redacted.push(format!("{}=****", flag)); - } else { - redacted.push(arg.clone()); - } - } else { - redacted.push(arg.clone()); - } - } - - redacted - } - #[cfg(unix)] async fn prepare_ttyd_socket() -> Result<(), String> { let socket_path = std::path::Path::new(TTYD_SOCKET_PATH); diff --git a/src/extensions/software_linux.rs b/src/extensions/software_linux.rs index a3757c79..ec4e7926 100644 --- a/src/extensions/software_linux.rs +++ b/src/extensions/software_linux.rs @@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str { ExtensionId::Ttyd => "/usr/bin/ttyd", ExtensionId::Gostc => "/usr/bin/gostc", ExtensionId::Easytier => "/usr/bin/easytier-core", + ExtensionId::Frpc => "/usr/bin/frpc", } } diff --git a/src/extensions/software_windows.rs b/src/extensions/software_windows.rs index 74f67a90..4e2f45a4 100644 --- a/src/extensions/software_windows.rs +++ b/src/extensions/software_windows.rs @@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str { ExtensionId::Ttyd => "ttyd.win32.exe", ExtensionId::Gostc => "gostc.exe", ExtensionId::Easytier => "easytier-core.exe", + ExtensionId::Frpc => "frpc.exe", } } diff --git a/src/extensions/types.rs b/src/extensions/types.rs index 527e63ae..e0e2c9ca 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -10,6 +10,7 @@ pub enum ExtensionId { Ttyd, Gostc, Easytier, + Frpc, } impl ExtensionId { @@ -18,7 +19,7 @@ impl ExtensionId { } pub fn all() -> &'static [ExtensionId] { - &[Self::Ttyd, Self::Gostc, Self::Easytier] + &[Self::Ttyd, Self::Gostc, Self::Easytier, Self::Frpc] } } @@ -28,6 +29,7 @@ impl std::fmt::Display for ExtensionId { Self::Ttyd => write!(f, "ttyd"), Self::Gostc => write!(f, "gostc"), Self::Easytier => write!(f, "easytier"), + Self::Frpc => write!(f, "frpc"), } } } @@ -40,6 +42,7 @@ impl std::str::FromStr for ExtensionId { "ttyd" => Ok(Self::Ttyd), "gostc" => Ok(Self::Gostc), "easytier" => Ok(Self::Easytier), + "frpc" => Ok(Self::Frpc), _ => Err(format!("Unknown extension: {}", s)), } } @@ -114,6 +117,85 @@ pub struct EasytierConfig { pub virtual_ip: Option, } +#[typeshare] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FrpProxyType { + Tcp, + Udp, + Http, + Https, + Stcp, + Sudp, + Xtcp, +} + +impl Default for FrpProxyType { + fn default() -> Self { + Self::Tcp + } +} + +#[typeshare] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FrpcConfigMode { + Quick, + Full, +} + +impl Default for FrpcConfigMode { + fn default() -> Self { + Self::Quick + } +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct FrpcConfig { + pub enabled: bool, + pub config_mode: FrpcConfigMode, + pub proxy_name: String, + pub proxy_type: FrpProxyType, + pub server_addr: String, + pub server_port: u16, + #[serde(skip_serializing_if = "String::is_empty")] + pub token: String, + pub local_ip: String, + pub local_port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_domain: Option, + #[serde(skip_serializing_if = "String::is_empty")] + pub secret_key: String, + pub tls: bool, + #[serde(skip_serializing_if = "String::is_empty")] + pub custom_toml: String, +} + +impl Default for FrpcConfig { + fn default() -> Self { + Self { + enabled: false, + config_mode: FrpcConfigMode::Quick, + proxy_name: String::new(), + proxy_type: FrpProxyType::Tcp, + server_addr: String::new(), + server_port: 7000, + token: String::new(), + local_ip: "127.0.0.1".to_string(), + local_port: 22, + remote_port: None, + custom_domain: None, + secret_key: String::new(), + tls: true, + custom_toml: String::new(), + } + } +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -121,6 +203,7 @@ pub struct ExtensionsConfig { pub ttyd: TtydConfig, pub gostc: GostcConfig, pub easytier: EasytierConfig, + pub frpc: FrpcConfig, } #[typeshare] @@ -154,12 +237,21 @@ pub struct EasytierInfo { pub config: EasytierConfig, } +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrpcInfo { + pub available: bool, + pub status: ExtensionStatus, + pub config: FrpcConfig, +} + #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtensionsStatus { pub ttyd: TtydInfo, pub gostc: GostcInfo, pub easytier: EasytierInfo, + pub frpc: FrpcInfo, } #[typeshare] diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index 2e7ee8c9..963364f7 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -4,12 +4,14 @@ 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, - GostcConfig, GostcInfo, TtydConfig, TtydInfo, + FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig, + TtydInfo, }; use crate::state::AppState; @@ -34,6 +36,46 @@ fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> { 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::().map_err(|e| { + AppError::BadRequest(format!("FRPC full configuration is not valid TOML: {}", e)) + })?; + } + } + Ok(()) +} + pub async fn list_extensions(State(state): State>) -> Json { let config = state.config.get(); let mgr = &state.extensions; @@ -54,6 +96,11 @@ pub async fn list_extensions(State(state): State>) -> Json, } +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct FrpcConfigUpdate { + pub enabled: Option, + pub config_mode: Option, + pub proxy_name: Option, + pub proxy_type: Option, + pub server_addr: Option, + pub server_port: Option, + pub token: Option, + pub local_ip: Option, + pub local_port: Option, + pub remote_port: Option>, + pub custom_domain: Option>, + pub secret_key: Option, + pub tls: Option, + pub custom_toml: Option, +} + pub async fn update_ttyd_config( State(state): State>, Json(req): Json, @@ -295,3 +361,81 @@ pub async fn update_easytier_config( Ok(Json(new_config.extensions.easytier.clone())) } + +pub async fn update_frpc_config( + State(state): State>, + Json(req): Json, +) -> Result> { + let current_config = state.config.get(); + let was_enabled = current_config.extensions.frpc.enabled; + let mut next_frpc = current_config.extensions.frpc.clone(); + + if let Some(enabled) = req.enabled { + next_frpc.enabled = enabled; + } + if let Some(config_mode) = req.config_mode { + next_frpc.config_mode = config_mode; + } + if let Some(ref proxy_name) = req.proxy_name { + next_frpc.proxy_name = proxy_name.clone(); + } + if let Some(proxy_type) = req.proxy_type { + next_frpc.proxy_type = proxy_type; + } + if let Some(ref addr) = req.server_addr { + next_frpc.server_addr = addr.clone(); + } + if let Some(port) = req.server_port { + next_frpc.server_port = port; + } + if let Some(ref token) = req.token { + next_frpc.token = token.clone(); + } + if let Some(ref local_ip) = req.local_ip { + next_frpc.local_ip = local_ip.clone(); + } + if let Some(local_port) = req.local_port { + next_frpc.local_port = local_port; + } + if let Some(remote_port) = req.remote_port { + next_frpc.remote_port = remote_port; + } + if let Some(custom_domain) = req.custom_domain { + next_frpc.custom_domain = custom_domain; + } + if let Some(ref secret_key) = req.secret_key { + next_frpc.secret_key = secret_key.clone(); + } + if let Some(tls) = req.tls { + next_frpc.tls = tls; + } + if let Some(ref custom_toml) = req.custom_toml { + next_frpc.custom_toml = custom_toml.clone(); + } + + if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) { + validate_frpc_enabled(&next_frpc)?; + } + + state + .config + .update(|config| { + config.extensions.frpc = next_frpc.clone(); + }) + .await?; + + let new_config = state.config.get(); + let is_enabled = new_config.extensions.frpc.enabled; + + if was_enabled && !is_enabled { + state.extensions.stop(ExtensionId::Frpc).await.ok(); + } else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Frpc) { + state + .extensions + .start(ExtensionId::Frpc, &new_config.extensions) + .await + .ok(); + } + + Ok(Json(new_config.extensions.frpc.clone())) +} diff --git a/src/web/routes.rs b/src/web/routes.rs index ed43e80b..e2deddfb 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -205,6 +205,10 @@ pub fn create_router(state: Arc) -> Router { "/extensions/easytier/config", patch(handlers::extensions::update_easytier_config), ) + .route( + "/extensions/frpc/config", + patch(handlers::extensions::update_frpc_config), + ) // Terminal (ttyd) reverse proxy - WebSocket and HTTP .route("/terminal", get(handlers::terminal::terminal_index)) .route("/terminal/", get(handlers::terminal::terminal_index)) diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 3a9eb834..243a123b 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -24,6 +24,8 @@ import type { GostcConfigUpdate, EasytierConfig, EasytierConfigUpdate, + FrpcConfig, + FrpcConfigUpdate, WebConfigResponse, WebConfigUpdate, } from '@/types/generated' @@ -159,6 +161,12 @@ export const extensionsApi = { method: 'PATCH', body: JSON.stringify(config), }), + + updateFrpc: (config: FrpcConfigUpdate) => + request('/extensions/frpc/config', { + method: 'PATCH', + body: JSON.stringify(config), + }), } export interface RustDeskConfigResponse { diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index a2342dee..49fe2ac9 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -522,6 +522,7 @@ export default { extRustdeskSubtitle: 'Remote graphical access via RustDesk', extRtspSubtitle: 'Provide an RTSP video stream for external clients', extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', + extFrpcSubtitle: 'NAT traversal through the FRP client', aboutDesc: 'Open and Lightweight IP-KVM Solution', deviceInfo: 'Device Info', deviceInfoDesc: 'Host system information', @@ -956,7 +957,7 @@ export default { binaryNotFound: '{path} not found, please install the required program', remoteAccess: { title: 'Remote Access', - desc: 'GOSTC NAT traversal and Easytier networking', + desc: 'GOSTC/FRPC NAT traversal and Easytier networking', }, ttyd: { title: 'Ttyd Web Terminal', @@ -987,6 +988,33 @@ export default { virtualIp: 'Virtual IP', virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)', }, + frpc: { + title: 'FRPC NAT Traversal', + desc: 'Connect to an frps server through the FRP client', + quickConfig: 'Quick Config', + fullConfig: 'Full Config', + fullConfigHint: 'Paste the provider TOML configuration file here', + fullConfigRequired: 'Enter the full frpc.toml configuration', + proxyType: 'Proxy Type', + proxyName: 'Proxy Name', + proxyNamePlaceholder: 'one-kvm-ssh', + proxyNameRequired: 'Enter the FRPC proxy name', + serverAddr: 'Server Address', + serverAddrPlaceholder: 'frps.example.com', + serverAddrRequired: 'Enter the FRPC server address', + serverPort: 'Server Port', + token: 'Token', + tokenRequired: 'Enter the FRPC token', + localIp: 'Local Address', + localIpRequired: 'Enter the FRPC local address', + localPort: 'Local Port', + remotePort: 'Remote Port', + remotePortRequired: 'TCP/UDP proxies require a remote port', + customDomain: 'Custom Domain', + customDomainPlaceholder: 'kvm.example.com', + secretKey: 'Secret Key', + tls: 'Enable TLS', + }, rustdesk: { title: 'RustDesk Remote', desc: 'Remote access via RustDesk client', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 61ae4353..a25fc309 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -521,6 +521,7 @@ export default { extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问', extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流', extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', + extFrpcSubtitle: '通过 FRP 客户端实现内网穿透', aboutDesc: '开放轻量的 IP-KVM 解决方案', deviceInfo: '设备信息', deviceInfoDesc: '主机系统信息', @@ -955,7 +956,7 @@ export default { binaryNotFound: '未找到 {path},请先安装对应程序', remoteAccess: { title: '远程访问', - desc: 'GOSTC 内网穿透与 Easytier 组网', + desc: 'GOSTC/FRPC 内网穿透与 Easytier 组网', }, ttyd: { title: 'Ttyd 网页终端', @@ -986,6 +987,33 @@ export default { virtualIp: '虚拟 IP', virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24)', }, + frpc: { + title: 'FRPC 内网穿透', + desc: '通过 FRP 客户端连接 frps 服务', + quickConfig: '快速配置', + fullConfig: '完整配置', + fullConfigHint: '可在此粘贴供应商 TOML 配置文件', + fullConfigRequired: '请填写完整 frpc.toml 配置', + proxyType: '代理类型', + proxyName: '代理名称', + proxyNamePlaceholder: 'one-kvm-ssh', + proxyNameRequired: '请填写 FRPC 代理名称', + serverAddr: '服务器地址', + serverAddrPlaceholder: 'frps.example.com', + serverAddrRequired: '请填写 FRPC 服务器地址', + serverPort: '服务器端口', + token: '认证令牌', + tokenRequired: '请填写 FRPC 认证令牌', + localIp: '本地地址', + localIpRequired: '请填写 FRPC 本地地址', + localPort: '本地端口', + remotePort: '远程端口', + remotePortRequired: 'TCP/UDP 代理需要填写远程端口', + customDomain: '自定义域名', + customDomainPlaceholder: 'kvm.example.com', + secretKey: '访问密钥', + tls: '启用 TLS', + }, rustdesk: { title: 'RustDesk 远程', desc: '使用 RustDesk 客户端进行远程访问', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 133c8339..191d13fc 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -175,10 +175,43 @@ export interface EasytierConfig { virtual_ip?: string; } +export enum FrpProxyType { + Tcp = "tcp", + Udp = "udp", + Http = "http", + Https = "https", + Stcp = "stcp", + Sudp = "sudp", + Xtcp = "xtcp", +} + +export enum FrpcConfigMode { + Quick = "quick", + Full = "full", +} + +export interface FrpcConfig { + enabled: boolean; + config_mode: FrpcConfigMode; + proxy_name: string; + proxy_type: FrpProxyType; + server_addr: string; + server_port: number; + token: string; + local_ip: string; + local_port: number; + remote_port?: number; + custom_domain?: string; + secret_key: string; + tls: boolean; + custom_toml: string; +} + export interface ExtensionsConfig { ttyd: TtydConfig; gostc: GostcConfig; easytier: EasytierConfig; + frpc: FrpcConfig; } export interface RustDeskConfig { @@ -277,6 +310,23 @@ export interface EasytierConfigUpdate { virtual_ip?: string; } +export interface FrpcConfigUpdate { + enabled?: boolean; + config_mode?: FrpcConfigMode; + proxy_name?: string; + proxy_type?: FrpProxyType; + server_addr?: string; + server_port?: number; + token?: string; + local_ip?: string; + local_port?: number; + remote_port?: number; + custom_domain?: string; + secret_key?: string; + tls?: boolean; + custom_toml?: string; +} + export type ExtensionStatus = | { state: "unavailable", data?: undefined } | { state: "stopped", data?: undefined } @@ -299,6 +349,7 @@ export enum ExtensionId { Ttyd = "ttyd", Gostc = "gostc", Easytier = "easytier", + Frpc = "frpc", } export interface ExtensionLogs { @@ -318,10 +369,17 @@ export interface GostcInfo { config: GostcConfig; } +export interface FrpcInfo { + available: boolean; + status: ExtensionStatus; + config: FrpcConfig; +} + export interface ExtensionsStatus { ttyd: TtydInfo; gostc: GostcInfo; easytier: EasytierInfo; + frpc: FrpcInfo; } export interface GostcConfigUpdate { @@ -597,4 +655,3 @@ export enum CanonicalKey { AltRight = "AltRight", MetaRight = "MetaRight", } - diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 2401a623..e385235b 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -43,6 +43,7 @@ import type { OtgHidProfile, OtgHidFunctions, } from '@/types/generated' +import { FrpProxyType, FrpcConfigMode } from '@/types/generated' import { formatFpsLabel, toConfigFps } from '@/lib/fps' import { useClipboard } from '@/composables/useClipboard' import { getVideoFormatState } from '@/lib/video-format-support' @@ -218,6 +219,7 @@ function selectSection(id: string) { function normalizeSettingsSection(value: unknown): SettingsSectionId | null { if (typeof value !== 'string') return null if (value === 'access-control') return 'account' + if (value === 'ext-frpc') return 'ext-remote-access' return isSettingsSectionId(value) ? value : null } @@ -315,11 +317,13 @@ const extensionLogs = ref>({ ttyd: [], gostc: [], easytier: [], + frpc: [], }) const showLogs = ref>({ ttyd: false, gostc: false, easytier: false, + frpc: false, }) const showTerminalDialog = ref(false) @@ -328,6 +332,22 @@ 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: '' }, + frpc: { + enabled: false, + config_mode: FrpcConfigMode.Quick, + proxy_name: '', + proxy_type: FrpProxyType.Tcp, + server_addr: '', + server_port: 7000, + token: '', + local_ip: '127.0.0.1', + local_port: 22, + remote_port: undefined as number | undefined, + custom_domain: '', + secret_key: '', + tls: true, + custom_toml: '', + }, }) const gostcValidationMessage = computed(() => { @@ -341,6 +361,25 @@ const easytierValidationMessage = computed(() => { return '' }) +const frpcRemotePortRequired = computed(() => ['tcp', 'udp'].includes(extConfig.value.frpc.proxy_type)) +const showFrpcRemotePort = computed(() => ['tcp', 'udp', 'stcp', 'sudp', 'xtcp'].includes(extConfig.value.frpc.proxy_type)) +const showFrpcCustomDomain = computed(() => ['http', 'https'].includes(extConfig.value.frpc.proxy_type)) +const showFrpcSecretKey = computed(() => ['stcp', 'sudp', 'xtcp'].includes(extConfig.value.frpc.proxy_type)) +const frpcQuickMode = computed(() => extConfig.value.frpc.config_mode === FrpcConfigMode.Quick) + +const frpcValidationMessage = computed(() => { + if (extConfig.value.frpc.config_mode === FrpcConfigMode.Full) { + if (!extConfig.value.frpc.custom_toml?.trim()) return t('extensions.frpc.fullConfigRequired') + return '' + } + if (!extConfig.value.frpc.proxy_name?.trim()) return t('extensions.frpc.proxyNameRequired') + if (!extConfig.value.frpc.server_addr?.trim()) return t('extensions.frpc.serverAddrRequired') + if (!extConfig.value.frpc.token) return t('extensions.frpc.tokenRequired') + if (!extConfig.value.frpc.local_ip?.trim()) return t('extensions.frpc.localIpRequired') + if (frpcRemotePortRequired.value && !extConfig.value.frpc.remote_port) return t('extensions.frpc.remotePortRequired') + return '' +}) + const rustdeskConfig = ref(null) const rustdeskStatus = ref(null) const rustdeskPassword = ref(null) @@ -1373,6 +1412,23 @@ async function loadExtensions() { peer_urls: easytier.peer_urls || [], virtual_ip: easytier.virtual_ip || '', } + const frpc = extensions.value.frpc.config + extConfig.value.frpc = { + enabled: frpc.enabled, + config_mode: frpc.config_mode || FrpcConfigMode.Quick, + proxy_name: frpc.proxy_name, + proxy_type: frpc.proxy_type, + server_addr: frpc.server_addr, + server_port: frpc.server_port, + token: frpc.token, + local_ip: frpc.local_ip, + local_port: frpc.local_port, + remote_port: frpc.remote_port, + custom_domain: frpc.custom_domain || '', + secret_key: frpc.secret_key, + tls: frpc.tls, + custom_toml: frpc.custom_toml || '', + } } } catch { } finally { @@ -1380,8 +1436,11 @@ async function loadExtensions() { } } -async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') { - if ((id === 'gostc' || id === 'easytier') && !validateExtensionConfig(id)) return +type ExtensionConfigId = 'ttyd' | 'gostc' | 'easytier' | 'frpc' +type ValidatedExtensionConfigId = Exclude + +async function startExtension(id: ExtensionConfigId) { + if (id !== 'ttyd' && !validateExtensionConfig(id)) return try { await extensionsApi.start(id) @@ -1390,7 +1449,7 @@ async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') { } } -async function stopExtension(id: 'ttyd' | 'gostc' | 'easytier') { +async function stopExtension(id: ExtensionConfigId) { try { await extensionsApi.stop(id) await loadExtensions() @@ -1398,7 +1457,7 @@ async function stopExtension(id: 'ttyd' | 'gostc' | 'easytier') { } } -async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') { +async function refreshExtensionLogs(id: ExtensionConfigId) { try { const result = await extensionsApi.logs(id, 100) extensionLogs.value[id] = result.logs @@ -1406,8 +1465,12 @@ async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') { } } -async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') { - if ((id === 'gostc' || id === 'easytier') && extConfig.value[id].enabled && !validateExtensionConfig(id)) return +async function saveExtensionConfig(id: ExtensionConfigId) { + if (id !== 'ttyd') { + const shouldValidate = extConfig.value[id].enabled + || (id === 'frpc' && extConfig.value.frpc.config_mode === FrpcConfigMode.Full) + if (shouldValidate && !validateExtensionConfig(id)) return + } loading.value = true try { @@ -1417,6 +1480,14 @@ async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') { await extensionsApi.updateGostc(extConfig.value.gostc) } else if (id === 'easytier') { await extensionsApi.updateEasytier(extConfig.value.easytier) + } else if (id === 'frpc') { + const frpc = extConfig.value.frpc + await extensionsApi.updateFrpc({ + ...frpc, + remote_port: frpcQuickMode.value && showFrpcRemotePort.value ? frpc.remote_port : undefined, + custom_domain: frpcQuickMode.value && showFrpcCustomDomain.value ? frpc.custom_domain || undefined : undefined, + secret_key: frpcQuickMode.value && showFrpcSecretKey.value ? frpc.secret_key : '', + }) } await loadExtensions() saved.value = true @@ -1662,10 +1733,15 @@ function showValidationError(message: string): boolean { return false } -function validateExtensionConfig(id: 'gostc' | 'easytier'): boolean { - const message = id === 'gostc' - ? gostcValidationMessage.value - : easytierValidationMessage.value +function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean { + let message = '' + if (id === 'gostc') { + message = gostcValidationMessage.value + } else if (id === 'easytier') { + message = easytierValidationMessage.value + } else { + message = frpcValidationMessage.value + } return !message || showValidationError(message) } @@ -3982,6 +4058,183 @@ watch(isWindows, () => { {{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }} + + + +
+
+ {{ t('extensions.frpc.title') }} + {{ t('extensions.frpc.desc') }} +
+ + {{ extensions?.frpc?.available ? t('extensions.available') : t('extensions.unavailable') }} + +
+
+ +
+ {{ t('extensions.binaryNotFound', { path: isWindows ? 'frpc.exe' : '/usr/bin/frpc' }) }} +
+