From 5de7ecd4c50b359f3460d63141f61acfb5c3764d Mon Sep 17 00:00:00 2001
From: mofeng-git
Date: Sat, 13 Jun 2026 16:05:34 +0800
Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20frp=20?=
=?UTF-8?q?=E8=BF=9C=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
+
+
+ {{ t('settings.ch9329StringLengthWarning') }}
+
+
+
+ {{ t('settings.ch9329DescriptorLoading') }}
+
{{ t('settings.unsavedChangesHint') }}
-